diff --git a/backend/tests/plugins/test_antispam_api.py b/backend/tests/plugins/test_antispam_api.py new file mode 100644 index 0000000..2c478d5 --- /dev/null +++ b/backend/tests/plugins/test_antispam_api.py @@ -0,0 +1,161 @@ +"""Antispam HTTP surface + RLS isolation (SPEC §C.6, acceptance 1 and 4). + +Exercises the real vertical: entry-point discovery -> router at +/api/plugins/antispam -> SDK auth -> RLS-scoped session -> the plg_antispam_* +tables. Requires the plugin installed and `install antispam` run. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator + +import httpx +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete, text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import get_settings +from app.identity.passwords import hash_password +from app.identity.sessions import create_session +from app.main import create_app +from app.models import User + +BASE = "/api/plugins/antispam" + + +async def _mk_user(sm: async_sessionmaker) -> uuid.UUID: # type: ignore[type-arg] + async with sm() as s: + user = User( + handle=f"as{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw") + ) + s.add(user) + await s.commit() + return user.id + + +class _Ctx: + user_a: uuid.UUID + user_b: uuid.UUID + client_a: httpx.AsyncClient + client_b: httpx.AsyncClient + + +@pytest_asyncio.fixture +async def ctx() -> AsyncIterator[_Ctx]: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + c = _Ctx() + c.user_a = await _mk_user(sm) + c.user_b = await _mk_user(sm) + transport = ASGITransport(app=create_app()) + + async def client(uid: uuid.UUID) -> httpx.AsyncClient: + token = await create_session(uid, "pytest") + return httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + cookies={"session": token, "csrf": "csrf"}, + headers={"X-CSRF": "csrf"}, + ) + + c.client_a = await client(c.user_a) + c.client_b = await client(c.user_b) + try: + yield c + finally: + await c.client_a.aclose() + await c.client_b.aclose() + async with sm() as s: + for table in ("plg_antispam_rules", "plg_antispam_settings", "plg_antispam_verdicts"): + await s.execute( + text(f"DELETE FROM {table} WHERE user_id IN (:a, :b)"), + {"a": str(c.user_a), "b": str(c.user_b)}, + ) + await s.execute(delete(User).where(User.id.in_([c.user_a, c.user_b]))) + await s.commit() + await engine.dispose() + + +async def test_settings_defaults_then_merge(ctx: _Ctx) -> None: + """A user who has never opened the page still gets the §C.3 defaults.""" + s = (await ctx.client_a.get(f"{BASE}/settings")).json() + assert (s["move_threshold"], s["banner_threshold"]) == (12.0, 6.0) + assert s["network_checks"] is False # privacy default (§C.2) + assert s["enabled_accounts"] == [] # empty = every account + + # PATCH merges: the field we leave out keeps its value. + s = (await ctx.client_a.patch(f"{BASE}/settings", json={"move_threshold": 15.0})).json() + assert s["move_threshold"] == 15.0 + assert s["banner_threshold"] == 6.0 + + +async def test_banner_threshold_cannot_exceed_move(ctx: _Ctx) -> None: + """A banner above the move score would never be seen — it is capped, not refused.""" + s = ( + await ctx.client_a.patch( + f"{BASE}/settings", json={"move_threshold": 10.0, "banner_threshold": 20.0} + ) + ).json() + assert s["banner_threshold"] == 10.0 + + +async def test_rules_crud_and_validation(ctx: _Ctx) -> None: + created = await ctx.client_a.post( + f"{BASE}/rules", json={"kind": "deny", "pattern": "@Bad.TEST"} + ) + assert created.status_code == 200 + assert created.json()["pattern"] == "@bad.test" # normalised + + listed = (await ctx.client_a.get(f"{BASE}/rules")).json() + assert [r["pattern"] for r in listed] == ["@bad.test"] + + # Re-adding the same pattern is idempotent, not a duplicate. + await ctx.client_a.post(f"{BASE}/rules", json={"kind": "deny", "pattern": "@bad.test"}) + assert len((await ctx.client_a.get(f"{BASE}/rules")).json()) == 1 + + assert ( + await ctx.client_a.post(f"{BASE}/rules", json={"kind": "deny", "pattern": "nonsense"}) + ).status_code == 400 + assert ( + await ctx.client_a.post(f"{BASE}/rules", json={"kind": "maybe", "pattern": "a@b.test"}) + ).status_code == 422 + + await ctx.client_a.delete(f"{BASE}/rules/{listed[0]['id']}") + assert (await ctx.client_a.get(f"{BASE}/rules")).json() == [] + + +async def test_rls_isolation_between_users(ctx: _Ctx) -> None: + """Acceptance 4's per-user isolation, at the storage layer.""" + await ctx.client_a.post(f"{BASE}/rules", json={"kind": "allow", "pattern": "a@only.test"}) + await ctx.client_a.patch(f"{BASE}/settings", json={"move_threshold": 9.0}) + + assert (await ctx.client_b.get(f"{BASE}/rules")).json() == [] + assert (await ctx.client_b.get(f"{BASE}/settings")).json()["move_threshold"] == 12.0 + + # B cannot delete A's rule even knowing its id. + a_rule = (await ctx.client_a.get(f"{BASE}/rules")).json()[0] + await ctx.client_b.delete(f"{BASE}/rules/{a_rule['id']}") + assert len((await ctx.client_a.get(f"{BASE}/rules")).json()) == 1 + + +async def test_verdict_absent_for_unclassified_message(ctx: _Ctx) -> None: + assert (await ctx.client_a.get(f"{BASE}/verdict/{uuid.uuid4()}")).json() is None + + +async def test_stats_work_without_an_engine(ctx: _Ctx) -> None: + """The stats page must render on a box where Rspamd is not running.""" + body = (await ctx.client_a.get(f"{BASE}/stats")).json() + assert body["counts"] == {} + assert body["top_symbols"] == [] + assert body["bayes"] is None # unreachable engine, not an error + + +async def test_ui_bundles_are_served_and_gated(ctx: _Ctx) -> None: + for name in ("settings.js", "banner.js", "actions.js"): + r = await ctx.client_a.get(f"{BASE}/ui/{name}") + assert r.status_code == 200 + assert "javascript" in r.headers["content-type"] + assert (await ctx.client_a.get(f"{BASE}/ui/../hook.py")).status_code == 404 diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py index 5ee573a..03b8aac 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py @@ -18,14 +18,26 @@ from __future__ import annotations from pathlib import Path -from app.plugins.sdk import Plugin +from app.plugins.sdk import Plugin, Slot, UiManifest from webmail_plugin_antispam.hook import on_message_new +from webmail_plugin_antispam.router import router _HERE = Path(__file__).parent +_UI = "/api/plugins/antispam/ui" plugin = Plugin( name="antispam", version="0.1.0", hooks={"message.new": on_message_new}, + router=router, migrations=str(_HERE / "migrations" / "versions"), + ui_manifest=UiManifest( + slots=[ + Slot(name="settings.page", bundle=f"{_UI}/settings.js"), + # 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"), + Slot(name="message.actions", bundle=f"{_UI}/actions.js"), + ], + ), ) diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/migrations/versions/0001_init.py b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/migrations/versions/0001_init.py index a41fc9d..f909bd0 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/migrations/versions/0001_init.py +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/migrations/versions/0001_init.py @@ -72,9 +72,7 @@ def upgrade() -> None: "CREATE INDEX ix_plg_antispam_verdicts_user_action " "ON plg_antispam_verdicts (user_id, action)" ) - op.execute( - "CREATE INDEX ix_plg_antispam_rules_user_kind ON plg_antispam_rules (user_id, kind)" - ) + op.execute("CREATE INDEX ix_plg_antispam_rules_user_kind ON plg_antispam_rules (user_id, kind)") enable_rls("plg_antispam_settings") enable_rls("plg_antispam_rules") diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/router.py b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/router.py new file mode 100644 index 0000000..30a9cf7 --- /dev/null +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/router.py @@ -0,0 +1,266 @@ +"""HTTP surface for the antispam plugin, mounted at /api/plugins/antispam (§C.6). + +Everything here is user-scoped through the SDK session, which applies RLS on the +plugin's own tables — a user cannot read or change another user's thresholds, +lists or verdicts. +""" + +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from sqlalchemy import text + +from app.plugins.sdk import CurrentUserId, require_csrf, sdk +from webmail_plugin_antispam import policy, rspamd +from webmail_plugin_antispam.schemas import ( + LearnIn, + RuleIn, + RuleOut, + SettingsIn, + SettingsOut, + StatsOut, + VerdictOut, +) + +router = APIRouter() + +_UI = Path(__file__).resolve().parent / "ui" +_BUNDLES = {"settings.js", "banner.js", "actions.js"} +_SDK = sdk("antispam") + + +# --- settings ---------------------------------------------------------------- + + +@router.get("/settings", response_model=SettingsOut) +async def get_settings(user_id: CurrentUserId) -> SettingsOut: + s = await policy.load_settings(user_id) + return SettingsOut( + enabled_accounts=s.enabled_accounts, + move_threshold=s.move_threshold, + banner_threshold=s.banner_threshold, + network_checks=s.network_checks, + learn_from_provider_junk=s.learn_from_provider_junk, + ) + + +@router.patch("/settings", response_model=SettingsOut, dependencies=[Depends(require_csrf)]) +async def patch_settings(body: SettingsIn, user_id: CurrentUserId) -> SettingsOut: + current = await policy.load_settings(user_id) + merged = { + "enabled_accounts": ( + current.enabled_accounts if body.enabled_accounts is None else body.enabled_accounts + ), + "move_threshold": body.move_threshold or current.move_threshold, + "banner_threshold": body.banner_threshold or current.banner_threshold, + "network_checks": ( + current.network_checks if body.network_checks is None else body.network_checks + ), + "learn_from_provider_junk": ( + current.learn_from_provider_junk + if body.learn_from_provider_junk is None + else body.learn_from_provider_junk + ), + } + # A banner that fires later than the move would never be seen: anything past + # it is already gone to Junk. Keep the pair coherent rather than rejecting. + if merged["banner_threshold"] > merged["move_threshold"]: + merged["banner_threshold"] = merged["move_threshold"] + + # asyncpg binds arrays as Python lists — a "{a,b}" literal is rejected. Drop + # anything that is not a uuid rather than 500 on a malformed account id. + account_ids: list[uuid.UUID] = [] + for raw in merged["enabled_accounts"]: + try: + account_ids.append(uuid.UUID(str(raw))) + except (ValueError, AttributeError, TypeError): + continue + merged["enabled_accounts"] = [str(a) for a in account_ids] + + async with _SDK.session(user_id) as s: + await s.execute( + text( + "INSERT INTO plg_antispam_settings (user_id, enabled_accounts, move_threshold, " + " banner_threshold, network_checks, learn_from_provider_junk) " + "VALUES (:u, CAST(:ea AS uuid[]), :mt, :bt, :nc, :lp) " + "ON CONFLICT (user_id) DO UPDATE SET " + " enabled_accounts = EXCLUDED.enabled_accounts, " + " move_threshold = EXCLUDED.move_threshold, " + " banner_threshold = EXCLUDED.banner_threshold, " + " network_checks = EXCLUDED.network_checks, " + " learn_from_provider_junk = EXCLUDED.learn_from_provider_junk" + ), + { + "u": str(user_id), + "ea": account_ids, + "mt": merged["move_threshold"], + "bt": merged["banner_threshold"], + "nc": merged["network_checks"], + "lp": merged["learn_from_provider_junk"], + }, + ) + await s.commit() + return SettingsOut(**merged) + + +# --- allow / deny lists ------------------------------------------------------ + + +@router.get("/rules", response_model=list[RuleOut]) +async def list_rules(user_id: CurrentUserId) -> list[RuleOut]: + async with _SDK.session(user_id) as s: + rows = ( + await s.execute( + text( + "SELECT id, kind, pattern FROM plg_antispam_rules " + "WHERE user_id = :u ORDER BY kind, pattern" + ), + {"u": str(user_id)}, + ) + ).all() + return [RuleOut(id=r[0], kind=r[1], pattern=str(r[2])) for r in rows] + + +@router.post("/rules", response_model=RuleOut, dependencies=[Depends(require_csrf)]) +async def add_rule(body: RuleIn, user_id: CurrentUserId) -> RuleOut: + pattern = body.pattern.strip().lower() + if "@" not in pattern: + raise HTTPException( + status_code=400, + detail={"code": "bad_pattern", "message": "use an address or @domain"}, + ) + async with _SDK.session(user_id) as s: + row = ( + await s.execute( + text( + "INSERT INTO plg_antispam_rules (user_id, kind, pattern) " + "VALUES (:u, :k, :p) " + "ON CONFLICT (user_id, kind, pattern) DO UPDATE SET pattern = EXCLUDED.pattern " + "RETURNING id, kind, pattern" + ), + {"u": str(user_id), "k": body.kind, "p": pattern}, + ) + ).first() + await s.commit() + assert row is not None + return RuleOut(id=row[0], kind=row[1], pattern=str(row[2])) + + +@router.delete("/rules/{rule_id}", dependencies=[Depends(require_csrf)]) +async def delete_rule(rule_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, str]: + async with _SDK.session(user_id) as s: + await s.execute( + text("DELETE FROM plg_antispam_rules WHERE id = :i AND user_id = :u"), + {"i": str(rule_id), "u": str(user_id)}, + ) + await s.commit() + return {"status": "ok"} + + +# --- verdicts, learning, stats ---------------------------------------------- + + +@router.get("/verdict/{message_id}", response_model=VerdictOut | None) +async def get_verdict(message_id: uuid.UUID, user_id: CurrentUserId) -> VerdictOut | None: + """What the banner slot asks before deciding whether to render anything.""" + async with _SDK.session(user_id) as s: + row = ( + await s.execute( + text( + "SELECT message_id, score, action, symbols, learned " + "FROM plg_antispam_verdicts WHERE message_id = :m AND user_id = :u" + ), + {"m": str(message_id), "u": str(user_id)}, + ) + ).first() + if row is None: + return None + return VerdictOut( + message_id=row[0], + score=float(row[1]), + action=row[2], + symbols=list(row[3] or []), + learned=row[4], + ) + + +@router.post("/learn", dependencies=[Depends(require_csrf)]) +async def learn(body: LearnIn, user_id: CurrentUserId) -> dict[str, object]: + """Teach this user's Bayes, then file the message accordingly (§C.5). + + The move is what the user actually asked for, so it happens even when the + engine is unreachable — teaching is best-effort, filing is not. + """ + spam = body.verdict == "spam" + raw = await _SDK.messages.raw(user_id, body.message_id) + taught = False + if raw is not None: + taught = await rspamd.learn(raw, user_key=str(user_id), spam=spam) + + moved = await _SDK.actions.move_to_special( + user_id, body.message_id, "junk" if spam else "inbox" + ) + + async with _SDK.session(user_id) as s: + await s.execute( + text( + "UPDATE plg_antispam_verdicts SET learned = :l, action = :a " + "WHERE message_id = :m AND user_id = :u" + ), + { + "l": body.verdict, + "a": "moved" if spam else "none", + "m": str(body.message_id), + "u": str(user_id), + }, + ) + await s.commit() + return {"status": "ok", "taught": taught, "moved": moved} + + +@router.get("/stats", response_model=StatsOut) +async def stats(user_id: CurrentUserId) -> StatsOut: + async with _SDK.session(user_id) as s: + counts = { + r[0]: int(r[1]) + for r in ( + await s.execute( + text( + "SELECT action, count(*) FROM plg_antispam_verdicts " + "WHERE user_id = :u GROUP BY action" + ), + {"u": str(user_id)}, + ) + ).all() + } + top = ( + await s.execute( + text( + "SELECT sym, count(*) AS n FROM plg_antispam_verdicts, " + " jsonb_array_elements_text(symbols) AS sym " + "WHERE user_id = :u GROUP BY sym ORDER BY n DESC LIMIT 10" + ), + {"u": str(user_id)}, + ) + ).all() + bayes: dict[str, Any] | None = await rspamd.stats(user_key=str(user_id)) + return StatsOut( + counts=counts, + top_symbols=[{"symbol": r[0], "count": int(r[1])} for r in top], + bayes=bayes, + ) + + +# --- UI bundles -------------------------------------------------------------- + + +@router.get("/ui/{filename}") +async def bundle(filename: str) -> FileResponse: + if filename not in _BUNDLES: + raise HTTPException(status_code=404, detail={"code": "not_found", "message": "no bundle"}) + return FileResponse(_UI / filename, media_type="text/javascript") diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/rspamd.py b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/rspamd.py index bc894da..0a2f507 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/rspamd.py +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/rspamd.py @@ -85,9 +85,7 @@ def _headers(user_key: str, network_checks: bool) -> dict[str, str]: } -async def check( - raw: bytes, *, user_key: str, network_checks: bool = False -) -> Verdict | None: +async def check(raw: bytes, *, user_key: str, network_checks: bool = False) -> Verdict | None: """Score one raw RFC822 message. ``None`` means "could not classify".""" try: async with httpx.AsyncClient(timeout=TIMEOUT) as client: diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/schemas.py b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/schemas.py new file mode 100644 index 0000000..7c9fafc --- /dev/null +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/schemas.py @@ -0,0 +1,60 @@ +"""Request/response models for the antispam API (SPEC §C.6).""" + +from __future__ import annotations + +import uuid + +from pydantic import BaseModel, Field + + +class SettingsOut(BaseModel): + enabled_accounts: list[str] + move_threshold: float + banner_threshold: float + network_checks: bool + learn_from_provider_junk: bool + + +class SettingsIn(BaseModel): + """Merge semantics: omitted fields keep their current value.""" + + enabled_accounts: list[str] | None = None + # Bounds are guard rails, not opinions: 0 would file everything as spam and + # the engine's own scale tops out well below 100. + move_threshold: float | None = Field(default=None, gt=0, le=100) + banner_threshold: float | None = Field(default=None, gt=0, le=100) + network_checks: bool | None = None + learn_from_provider_junk: bool | None = None + + +class RuleOut(BaseModel): + id: uuid.UUID + kind: str + pattern: str + + +class RuleIn(BaseModel): + kind: str = Field(pattern="^(allow|deny)$") + # An address (a@b.test) or a whole domain (@b.test). + pattern: str = Field(min_length=3, max_length=320) + + +class LearnIn(BaseModel): + message_id: uuid.UUID + verdict: str = Field(pattern="^(spam|ham)$") + + +class VerdictOut(BaseModel): + """What the banner slot needs to explain itself.""" + + message_id: uuid.UUID + score: float + action: str + symbols: list[str] + learned: str | None + + +class StatsOut(BaseModel): + counts: dict[str, int] + top_symbols: list[dict[str, object]] + bayes: dict[str, object] | None diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/actions.js b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/actions.js new file mode 100644 index 0000000..c720421 --- /dev/null +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/actions.js @@ -0,0 +1,50 @@ +// antispam · message.actions slot (SPEC §C.6). Explicit teaching: the two +// buttons are the strongest training signal the engine gets, because the user +// is telling us about *this* message rather than about a threshold. + +export default function mount(el, ctx, props) { + const messageId = props && props.messageId; + if (!messageId) return; + el.style.display = "inline-flex"; + el.style.gap = "4px"; + el.style.alignItems = "center"; + + let busy = false; + + async function teach(verdict) { + if (busy) return; + busy = true; + render(); + try { + await ctx.api.pluginFetch("/learn", { + method: "POST", + body: JSON.stringify({ message_id: messageId, verdict }), + }); + // The message has moved folders; let the mail UI catch up. + window.dispatchEvent(new CustomEvent("webmail:antispam-learned")); + el.innerHTML = `${verdict === "spam" ? "Marked as spam" : "Marked as not spam"}`; + return; + } catch { + busy = false; + render(); + } + } + + function render() { + const d = busy ? " disabled" : ""; + el.innerHTML = + `` + + ``; + el.querySelectorAll("[data-v]").forEach((b) => + b.addEventListener("click", (e) => { + e.stopPropagation(); + teach(b.getAttribute("data-v")); + }), + ); + } + + render(); + return () => { + el.innerHTML = ""; + }; +} diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js new file mode 100644 index 0000000..a00b3dc --- /dev/null +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js @@ -0,0 +1,74 @@ +// antispam · message.banner slot (SPEC §C.6). The "uncertain middle": scored +// above the banner threshold but below the move threshold, so it was delivered +// normally and the user gets a warning plus one click to resolve it either way. +// +// Renders NOTHING unless this message actually has a `banner` verdict — the slot +// mounts on every message, and a spam warning on ordinary mail is worse than no +// warning at all. + +function esc(s) { + return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); +} + +export default function mount(el, ctx, props) { + const messageId = props && props.messageId; + if (!messageId) return; + let disposed = false; + + async function act(path, body) { + await ctx.api.pluginFetch(path, { method: "POST", body: JSON.stringify(body) }); + window.dispatchEvent(new CustomEvent("webmail:antispam-learned")); + if (!disposed) el.innerHTML = ""; + } + + (async () => { + let v = null; + try { + v = await ctx.api.pluginFetch(`/verdict/${messageId}`); + } catch { + return; // no verdict service, no banner — never block the message + } + if (disposed || !v || v.action !== "banner") return; + + const why = (v.symbols || []).slice(0, 4).join(", "); + el.innerHTML = ` +
Stats unavailable — is the engine running?
`; + const c = stats.counts || {}; + const rowsOf = Object.entries(c) + .map(([k, v]) => `| nothing classified yet |
Most common reasons
Loading…
`; + return; + } + el.className = "wm-root"; + el.innerHTML = ` +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.
+ +Higher is more permissive. A banner above the move score would never be seen, so it is capped there.
+Tick none to filter every account.
+ ${accountRows()} +An address, or @domain for everyone there. Beats any score.
+Filed into Junk without asking the engine.
+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.
+ +