Settings: a section per plugin, and call the folder Spam
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
@@ -96,7 +96,10 @@ def mount_routers(app: FastAPI) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _slot_dict(slot: Slot) -> dict[str, str]:
|
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]:
|
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]:
|
def build_ui_manifest() -> dict[str, Any]:
|
||||||
"""Aggregate every plugin's UI manifest for the frontend loader (SPEC §11.3)."""
|
"""Aggregate every plugin's UI manifest for the frontend loader (SPEC §11.3)."""
|
||||||
plugins = [
|
plugins = [_manifest_dict(p, p.ui_manifest) for p in get_plugins() if p.ui_manifest is not None]
|
||||||
_manifest_dict(p, p.ui_manifest) for p in get_plugins() if p.ui_manifest is not None
|
|
||||||
]
|
|
||||||
return {"plugins": plugins}
|
return {"plugins": plugins}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ class Slot:
|
|||||||
|
|
||||||
name: str
|
name: str
|
||||||
bundle: str # path served by the plugin router, resolved by the frontend loader
|
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:
|
def __post_init__(self) -> None:
|
||||||
if self.name not in SLOT_NAMES:
|
if self.name not in SLOT_NAMES:
|
||||||
|
|||||||
@@ -44,8 +44,19 @@ function SlotMount({
|
|||||||
return <div ref={ref} data-plugin-slot={slotName} data-plugin={pluginName} />;
|
return <div ref={ref} data-plugin-slot={slotName} data-plugin={pluginName} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PluginSlot({ name, props }: { name: string; props?: Record<string, unknown> }) {
|
export function PluginSlot({
|
||||||
const bindings = usePluginSlots(name);
|
name,
|
||||||
|
props,
|
||||||
|
plugin,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
/** 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;
|
if (bindings.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -37,3 +37,8 @@ export function usePluginPages(): PageBinding[] {
|
|||||||
.flatMap((p) => p.pages.map((pg) => ({ ...pg, plugin: p.name })))
|
.flatMap((p) => p.pages.map((pg) => ({ ...pg, plugin: p.name })))
|
||||||
.sort((a, b) => a.nav.order - b.nav.order);
|
.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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
export interface SlotDecl {
|
export interface SlotDecl {
|
||||||
name: string; // one of the SPEC §11.3 slot names
|
name: string; // one of the SPEC §11.3 slot names
|
||||||
bundle: string; // URL of the ESM bundle to mount (served by the plugin router)
|
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 {
|
export interface PageNavDecl {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useMe } from "../../lib/auth";
|
|||||||
import { useTheme } from "../../lib/theme";
|
import { useTheme } from "../../lib/theme";
|
||||||
import { setTimeFormat } from "../../lib/timeFormat";
|
import { setTimeFormat } from "../../lib/timeFormat";
|
||||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||||
|
import { usePluginSettingsSections } from "../../plugins/manifest";
|
||||||
import { AccountWizard } from "../wizard/AccountWizard";
|
import { AccountWizard } from "../wizard/AccountWizard";
|
||||||
|
|
||||||
const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const;
|
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),
|
// The add-account wizard renders inside this layout (route /settings/accounts/new),
|
||||||
// with the Accounts nav item highlighted.
|
// with the Accounts nav item highlighted.
|
||||||
const addingAccount = useLocation().pathname.endsWith("/accounts/new");
|
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 = (
|
const section = (
|
||||||
addingAccount
|
addingAccount
|
||||||
? "accounts"
|
? "accounts"
|
||||||
: SECTIONS.includes(params.section as Section)
|
: SECTIONS.includes(params.section as Section) || isPluginSection
|
||||||
? params.section
|
? params.section
|
||||||
: "profile"
|
: "profile"
|
||||||
) as Section;
|
) as Section;
|
||||||
@@ -58,6 +63,19 @@ export function Settings() {
|
|||||||
{t(`settings.${s}` as const)}
|
{t(`settings.${s}` as const)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{pluginSections.map((b) => (
|
||||||
|
<Link
|
||||||
|
key={b.plugin}
|
||||||
|
to={`/settings/${b.plugin}${fromQuery}`}
|
||||||
|
className={`block rounded px-3 py-2 text-sm ${
|
||||||
|
section === (b.plugin as Section)
|
||||||
|
? "bg-app-active font-medium text-app-fg"
|
||||||
|
: "hover:bg-app-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{b.title ?? b.plugin}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
|
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
|
||||||
@@ -67,11 +85,11 @@ export function Settings() {
|
|||||||
{section === "calendars" && <CalendarsSection />}
|
{section === "calendars" && <CalendarsSection />}
|
||||||
{section === "appearance" && <AppearanceSection />}
|
{section === "appearance" && <AppearanceSection />}
|
||||||
{section === "integrations" && <IntegrationsSection />}
|
{section === "integrations" && <IntegrationsSection />}
|
||||||
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
|
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section
|
||||||
Matches the accounts section width (max-w-5xl) with a gap above it. */}
|
per plugin, selected from the nav like any built-in section. */}
|
||||||
{!addingAccount && section === "accounts" && (
|
{isPluginSection && (
|
||||||
<div className="mt-8 max-w-5xl">
|
<div className="max-w-5xl">
|
||||||
<PluginSlot name="settings.page" />
|
<PluginSlot name="settings.page" plugin={section} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ plugin = Plugin(
|
|||||||
migrations=str(_HERE / "migrations" / "versions"),
|
migrations=str(_HERE / "migrations" / "versions"),
|
||||||
ui_manifest=UiManifest(
|
ui_manifest=UiManifest(
|
||||||
slots=[
|
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 —
|
# Mounts on every message but renders only for a `banner` verdict —
|
||||||
# a spam warning on ordinary mail is worse than no warning at all.
|
# a spam warning on ordinary mail is worse than no warning at all.
|
||||||
Slot(name="message.banner", bundle=f"{_UI}/banner.js"),
|
Slot(name="message.banner", bundle=f"{_UI}/banner.js"),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function mount(el, ctx, props) {
|
|||||||
function render() {
|
function render() {
|
||||||
const d = busy ? " disabled" : "";
|
const d = busy ? " disabled" : "";
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
`<button class="wm-btn-ghost" data-v="spam" title="Move to Junk and teach the filter"${d}>Spam</button>` +
|
`<button class="wm-btn-ghost" data-v="spam" title="Move to Spam and teach the filter"${d}>Spam</button>` +
|
||||||
`<button class="wm-btn-ghost" data-v="ham" title="Move back to Inbox and teach the filter"${d}>Not spam</button>`;
|
`<button class="wm-btn-ghost" data-v="ham" title="Move back to Inbox and teach the filter"${d}>Not spam</button>`;
|
||||||
el.querySelectorAll("[data-v]").forEach((b) =>
|
el.querySelectorAll("[data-v]").forEach((b) =>
|
||||||
b.addEventListener("click", (e) => {
|
b.addEventListener("click", (e) => {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function mount(el, ctx, props) {
|
|||||||
<strong>Looks like spam</strong>
|
<strong>Looks like spam</strong>
|
||||||
<span class="wm-muted">score ${Number(v.score).toFixed(1)}${why ? ` · ${esc(why)}` : ""}</span>
|
<span class="wm-muted">score ${Number(v.score).toFixed(1)}${why ? ` · ${esc(why)}` : ""}</span>
|
||||||
<div class="wm-row">
|
<div class="wm-row">
|
||||||
<button class="wm-btn" data-a="junk">Move to Junk</button>
|
<button class="wm-btn" data-a="junk">Move to Spam</button>
|
||||||
<button class="wm-btn-ghost" data-a="ham">Not spam</button>
|
<button class="wm-btn-ghost" data-a="ham">Not spam</button>
|
||||||
<button class="wm-btn-ghost" data-a="trust">Always trust sender</button>
|
<button class="wm-btn-ghost" data-a="trust">Always trust sender</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,11 +78,11 @@ export default function mount(el, ctx) {
|
|||||||
}
|
}
|
||||||
el.className = "wm-root";
|
el.className = "wm-root";
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<h2 class="wm-h2">Spam filtering</h2>
|
<h2 class="wm-h2">Antispam filtering</h2>
|
||||||
<p class="wm-muted">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.</p>
|
<p class="wm-muted">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.</p>
|
||||||
|
|
||||||
<div class="wm-card">
|
<div class="wm-card">
|
||||||
<label class="wm-row">Move to Junk at score
|
<label class="wm-row">Move to Spam at score
|
||||||
<input class="wm-input" type="number" step="0.5" min="0.5" data-f="move_threshold" value="${settings.move_threshold}">
|
<input class="wm-input" type="number" step="0.5" min="0.5" data-f="move_threshold" value="${settings.move_threshold}">
|
||||||
</label>
|
</label>
|
||||||
<label class="wm-row">Warn with a banner at score
|
<label class="wm-row">Warn with a banner at score
|
||||||
@@ -106,7 +106,7 @@ export default function mount(el, ctx) {
|
|||||||
|
|
||||||
<div class="wm-card">
|
<div class="wm-card">
|
||||||
<strong>Always refuse</strong>
|
<strong>Always refuse</strong>
|
||||||
<p class="wm-faint">Filed into Junk without asking the engine.</p>
|
<p class="wm-faint">Filed into Spam without asking the engine.</p>
|
||||||
<ul class="wm-list">${ruleRows("deny")}</ul>
|
<ul class="wm-list">${ruleRows("deny")}</ul>
|
||||||
<div class="wm-row"><input class="wm-input" data-new="deny" placeholder="sender@example.com or @example.com"><button class="wm-btn" data-add="deny">Add</button></div>
|
<div class="wm-row"><input class="wm-input" data-new="deny" placeholder="sender@example.com or @example.com"><button class="wm-btn" data-add="deny">Add</button></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +114,7 @@ export default function mount(el, ctx) {
|
|||||||
<div class="wm-card">
|
<div class="wm-card">
|
||||||
<label class="wm-row"><input type="checkbox" data-f="network_checks"${settings.network_checks ? " checked" : ""}> Allow online reputation checks</label>
|
<label class="wm-row"><input type="checkbox" data-f="network_checks"${settings.network_checks ? " checked" : ""}> Allow online reputation checks</label>
|
||||||
<p class="wm-faint">Off by default. These send hashes of your mail and the links it contains to third-party servers. They also need the modules enabled on the engine itself.</p>
|
<p class="wm-faint">Off by default. These send hashes of your mail and the links it contains to third-party servers. They also need the modules enabled on the engine itself.</p>
|
||||||
<label class="wm-row"><input type="checkbox" data-f="learn_from_provider_junk"${settings.learn_from_provider_junk ? " checked" : ""}> Learn from mail your provider already marked as junk</label>
|
<label class="wm-row"><input type="checkbox" data-f="learn_from_provider_junk"${settings.learn_from_provider_junk ? " checked" : ""}> Learn from mail your provider already marked as spam</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wm-card"><strong>Activity</strong>${statsBlock()}</div>`;
|
<div class="wm-card"><strong>Activity</strong>${statsBlock()}</div>`;
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ plugin = Plugin(
|
|||||||
hooks={"message.new": on_message_new},
|
hooks={"message.new": on_message_new},
|
||||||
router=router,
|
router=router,
|
||||||
ui_manifest=UiManifest(
|
ui_manifest=UiManifest(
|
||||||
slots=[Slot(name="settings.page", bundle="/api/plugins/filters/ui/settings.js")],
|
slots=[
|
||||||
|
Slot(
|
||||||
|
name="settings.page",
|
||||||
|
bundle="/api/plugins/filters/ui/settings.js",
|
||||||
|
title="Mail filters",
|
||||||
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user