Antispam: "Mark as not spam" must survive having marked it as spam

In the spam folder the menu showed a disabled "Marked as spam" for any message
the user had marked, hiding the one action that corrects the mistake. The
already-taught check ran before the folder check, so it suppressed both
directions rather than the one already taken.

The folder now decides what is on offer first, and only the lesson already
taught is suppressed — never its opposite. A message in the spam folder always
offers "Mark as not spam", whatever it was taught.

Reported from the running app: "Klaudia, czy wpisujemy Cię na listę obecnych?"
(folder=junk, learned=spam) showed the disabled state, while two provider-filed
messages beside it with no verdict row behaved correctly.

Adds a vitest for the bundle's decision table. It is small but has now been
wrong twice, and the mount contract makes these bundles directly drivable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-17 19:51:31 +02:00
co-authored by Claude Opus 5
parent 8e3252679a
commit d5df1654ba
2 changed files with 65 additions and 5 deletions
+57
View File
@@ -0,0 +1,57 @@
// The antispam plugin's Actions-menu bundle (plugins/webmail_plugin_antispam).
// Plugin bundles are plain ESM with a documented mount contract, so they can be
// driven directly. This one earns a test: its decision table is small but has
// been wrong twice, most recently hiding "Mark as not spam" on exactly the
// message a user needs it for — one they had marked as spam by mistake.
import { describe, expect, it } from "vitest";
// Plugin bundles are untyped plain ESM by contract (docs/plugins.md §8) and
// live outside the frontend package, so there is no declaration to find.
// @ts-expect-error - untyped plugin bundle
import mount from "../../../plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js";
function ctxWith(special_use: string | null, learned: string | null) {
return {
api: {
pluginFetch: async () => ({
message_id: "m1",
special_use,
verdict: learned ? { learned } : null,
}),
},
};
}
async function render(special_use: string | null, learned: string | null): Promise<string> {
const el = document.createElement("div");
mount(el, ctxWith(special_use, learned), { messageId: "m1" });
await new Promise((r) => setTimeout(r, 0)); // the bundle fetches before rendering
return el.textContent ?? "";
}
describe("antispam Actions-menu item", () => {
it("offers Mark as Spam on untouched inbox mail", async () => {
expect(await render("inbox", null)).toBe("Mark as Spam");
});
it("offers Mark as not spam in the spam folder", async () => {
expect(await render("junk", null)).toBe("Mark as not spam");
});
it("still offers Mark as not spam for a message the user marked as spam", async () => {
// The regression: a message in the spam folder that was taught "spam" is
// precisely the one someone needs to correct, so the action must stay.
expect(await render("junk", "spam")).toBe("Mark as not spam");
});
it("suppresses only the lesson already taught", async () => {
expect(await render("inbox", "spam")).toBe("Marked as spam");
});
it("shows nothing where no move is a verdict about spam", async () => {
for (const folder of ["sent", "drafts", "trash", null]) {
expect(await render(folder, null)).toBe("");
}
});
});
@@ -25,18 +25,21 @@ export default function mount(el, ctx, props) {
} }
if (disposed || !data) return; if (disposed || !data) return;
// Already taught: show it, greyed, rather than offering the same lesson. // The folder decides which action is on offer — FIRST. A message sitting in
// the spam folder offers "not spam" no matter what it was taught, because
// that is exactly the message someone needs to correct.
const action = MEANINGFUL[data.special_use];
if (!action) return;
// Only suppress the lesson we already taught, never its opposite.
const learned = data.verdict && data.verdict.learned; const learned = data.verdict && data.verdict.learned;
if (learned) { if (learned === action.verdict) {
el.innerHTML = `<button class="wm-menu-item" disabled>${ el.innerHTML = `<button class="wm-menu-item" disabled>${
learned === "spam" ? "Marked as spam" : "Marked as not spam" learned === "spam" ? "Marked as spam" : "Marked as not spam"
}</button>`; }</button>`;
return; return;
} }
const action = MEANINGFUL[data.special_use];
if (!action) return;
el.innerHTML = `<button class="wm-menu-item">${action.label}</button>`; el.innerHTML = `<button class="wm-menu-item">${action.label}</button>`;
el.querySelector("button").addEventListener("click", async () => { el.querySelector("button").addEventListener("click", async () => {
try { try {