From d5df1654ba8d7f6e5eb25e7dac7c45f22d5fd84a Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Thu, 17 Sep 2026 19:51:31 +0200 Subject: [PATCH] Antispam: "Mark as not spam" must survive having marked it as spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo --- frontend/src/test/antispamMenu.test.ts | 57 +++++++++++++++++++ .../webmail_plugin_antispam/ui/menu.js | 13 +++-- 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 frontend/src/test/antispamMenu.test.ts diff --git a/frontend/src/test/antispamMenu.test.ts b/frontend/src/test/antispamMenu.test.ts new file mode 100644 index 0000000..f5558e7 --- /dev/null +++ b/frontend/src/test/antispamMenu.test.ts @@ -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 { + 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(""); + } + }); +}); diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js index 70ec52f..7faec9a 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js @@ -25,18 +25,21 @@ export default function mount(el, ctx, props) { } 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; - if (learned) { + if (learned === action.verdict) { el.innerHTML = ``; return; } - const action = MEANINGFUL[data.special_use]; - if (!action) return; - el.innerHTML = ``; el.querySelector("button").addEventListener("click", async () => { try {