// Covers §23's email updates: confirming before anything is sent, who is told what, stopping by // link or one-click, and nothing offered without SMTP. import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test"; import { Browser, type Instance, instance } from "./browser.ts"; import { makeRepos } from "./fixture.ts"; let clock = Date.parse("2026-10-08T12:00:00Z"); const tick = (seconds: number) => { clock += seconds * 1000; setSystemTime(new Date(clock)); }; type Queued = { recipient: string; subject: string; body: string; unsubscribe: string }; describe("following an issue by email (§23)", () => { let inst: Instance; let operator: Browser; const queued = (to: string) => inst.kepr .query( "SELECT recipient, subject, body, unsubscribe FROM email_queue WHERE recipient = ? ORDER BY id", ) .all(to); const stamped = async (who: Browser, path: string) => { const page = await (await who.request(path)).text(); tick(4); return /name="stamp" value="([^"]+)"/.exec(page)?.[1] ?? ""; }; const send = async (who: Browser, path: string, fields: Record, from = path) => who.request(path, { method: "POST", body: new URLSearchParams({ website: "", stamp: await stamped(who, from), ...fields }), }); const link = (body: string, action: string) => new RegExp(`https://kepr\\.example(/app/issues/1/${action}\\?f=\\d+&t=[0-9a-f]+)`).exec( body, )?.[1] ?? ""; beforeAll(async () => { setSystemTime(new Date(clock)); inst = await instance((await makeRepos(["app"])).bucketDir); operator = new Browser(inst.app); await operator.signIn(); await operator.post("/admin/repos/app", { public: "1" }); const reporter = new Browser(inst.app, "198.51.100.60"); await send(reporter, "/app/issues/new", { title: "Crash", body: "It broke.", type: "bug" }); }); afterAll(() => setSystemTime()); test("without SMTP, nothing offers email and following is not there", async () => { const thread = await (await inst.app.request("/app/issues/1")).text(); expect(thread).not.toContain('id="follow"'); expect(thread).not.toContain('name="email"'); const reader = new Browser(inst.app, "198.51.100.61"); const response = await send( reader, "/app/issues/1/follow", { email: "a@example.com" }, "/app/issues/1", ); expect(response.status).toBe(404); }); test("an address gets one confirmation, and nothing else until it confirms", async () => { inst.config.smtp = { host: "smtp.example", port: 587, security: "starttls", username: "", password: "", from: "kepr@kepr.example", to: "june@kepr.example", }; const reader = new Browser(inst.app, "198.51.100.61"); const thread = await (await reader.request("/app/issues/1")).text(); expect(thread).toContain('id="follow"'); const follow = () => send(reader, "/app/issues/1/follow", { email: "Ada@Example.com" }, "/app/issues/1"); const response = await follow(); expect(response.status).toBe(303); expect(response.headers.get("location")).toBe("/app/issues/1?notice=check"); expect((await follow()).headers.get("location")).toBe("/app/issues/1?notice=check"); const [confirm, ...more] = queued("ada@example.com"); expect(more).toEqual([]); expect(confirm?.subject).toBe("Confirm email updates for app #1"); expect(confirm?.unsubscribe).toBe(""); const other = new Browser(inst.app, "198.51.100.62"); await send(other, "/app/issues/1", { body: "Me too." }); expect(queued("ada@example.com")).toHaveLength(1); const wrong = await inst.app.request( link(confirm?.body ?? "", "follow").replace(/t=\w+/, "t=00"), ); expect(wrong.headers.get("location")).toBe("/app/issues/1?notice=expired"); const opened = await inst.app.request(link(confirm?.body ?? "", "follow")); expect(opened.headers.get("location")).toBe("/app/issues/1?notice=following"); expect(await (await inst.app.request("/app/issues/1?notice=following")).text()).toContain( "You'll get an email for each reply", ); }); test("followers hear of each reply but their own, with a stop link and header", async () => { const other = new Browser(inst.app, "198.51.100.62"); await send(other, "/app/issues/1", { body: "Still .", name: "Bo" }); const [, reply] = queued("ada@example.com"); expect(reply?.subject).toBe("Re: Crash (app #1)"); expect(reply?.body).toContain("Bo replied:\n\nStill ."); expect(reply?.body).toContain("https://kepr.example/app/issues/1#entry-"); expect(reply?.unsubscribe).toMatch(/^\/app\/issues\/1\/unfollow\?f=\d+&t=[0-9a-f]+$/); const ada = new Browser(inst.app, "198.51.100.61"); await send(ada, "/app/issues/1", { body: "Thanks.", email: "ada@example.com" }); expect(queued("ada@example.com")).toHaveLength(2); }); test("the operator's reply and close reach followers too", async () => { await operator.get("/admin/issues/app/1"); await operator.post("/admin/issues/app/1", { action: "reply", body: "Looking into it." }); await operator.post("/admin/issues/app/1", { action: "close", fixed_in: "" }); const [, , reply, closed] = queued("ada@example.com"); expect(reply?.body).toContain("The operator replied:\n\nLooking into it."); expect(closed?.body).toContain('"Crash" was closed.'); }); test("a stop link asks first, and an inbox's one-click stops at once", async () => { const stop = queued("ada@example.com")[1]?.unsubscribe ?? ""; const page = await (await inst.app.request(stop)).text(); expect(page).toContain("stop emails"); expect(queued("ada@example.com")).toHaveLength(4); const oneClick = await inst.app.request(stop, { method: "POST", body: new URLSearchParams({ "List-Unsubscribe": "One-Click" }), }); expect(oneClick.status).toBe(200); await operator.post("/admin/issues/app/1", { action: "reopen" }); expect(queued("ada@example.com")).toHaveLength(4); const again = await inst.app.request(stop, { method: "POST" }); expect(again.headers.get("location")).toBe("/app/issues/1?notice=expired"); }); test("filing can follow the issue at the contact address", async () => { const reporter = new Browser(inst.app, "198.51.100.63"); const filed = await send(reporter, "/app/issues/new", { title: "Dark mode", body: "Please.", type: "idea", email: "sam@example.com", follow: "1", }); expect(filed.headers.get("location")).toBe("/app/issues/2?notice=check"); expect(queued("sam@example.com")[0]?.subject).toBe("Confirm email updates for app #2"); const bad = await send(reporter, "/app/issues/new", { title: "x", body: "y", type: "bug", email: "not an address", }); expect(bad.status).toBe(400); }); test("a spent link cannot stop a follower that reuses its id", async () => { // A follower's id is a rowid, so SQLite hands a stopped follower's id to the next one. const gone = new Browser(inst.app, "198.51.100.70"); await send(gone, "/app/issues/1/follow", { email: "gone@example.com" }, "/app/issues/1"); const stale = link(queued("gone@example.com")[0]?.body ?? "", "follow").replace( "/follow?", "/unfollow?", ); const freed = Number(/f=(\d+)/.exec(stale)?.[1]); await inst.app.request(stale, { method: "POST" }); const next = new Browser(inst.app, "198.51.100.71"); await send(next, "/app/issues/1/follow", { email: "next@example.com" }, "/app/issues/1"); const reused = inst.kepr .query<{ id: number }, [string]>("SELECT id FROM issue_followers WHERE email = ?") .get("next@example.com")?.id; expect(reused).toBe(freed); await inst.app.request(link(queued("next@example.com")[0]?.body ?? "", "follow")); const replay = await inst.app.request(stale, { method: "POST" }); expect(replay.headers.get("location")).toBe("/app/issues/1?notice=expired"); const other = new Browser(inst.app, "198.51.100.72"); await send(other, "/app/issues/1", { body: "One more." }); expect(queued("next@example.com")).toHaveLength(2); }); test("a blocked address cannot follow an issue", async () => { const spammer = new Browser(inst.app, "198.51.100.81"); await send(spammer, "/app/issues/1", { body: "Buy my thing." }); const entry = inst.kepr .query<{ id: number }, []>("SELECT id FROM issue_entries ORDER BY id DESC LIMIT 1") .get()?.id; await operator.get("/admin/issues/app/1"); await operator.post("/admin/issues/app/1", { action: "block_entry", entry: String(entry) }); const refused = await send( spammer, "/app/issues/1/follow", { email: "spam@example.com" }, "/app/issues/1", ); expect(refused.status).toBe(403); expect(queued("spam@example.com")).toHaveLength(0); }); test("twenty follows an hour per address, and stopping one does not free the hour", async () => { const reader = new Browser(inst.app, "198.51.100.80"); const addresses = Array.from({ length: 20 }, (_, n) => `many${n}@example.com`); for (const address of addresses) { const response = await send( reader, "/app/issues/1/follow", { email: address }, "/app/issues/1", ); expect(response.headers.get("location")).toBe("/app/issues/1?notice=check"); } // A confirmation email carries the stop link to the address that asked, so a follower row // would hand the allowance back at will; the request log keeps the hour spent. const stop = link(queued(addresses[0] ?? "")[0]?.body ?? "", "follow").replace( "/follow?", "/unfollow?", ); await inst.app.request(stop, { method: "POST" }); const over = await send( reader, "/app/issues/1/follow", { email: "one-too-many@example.com" }, "/app/issues/1", ); expect(over.status).toBe(429); }); test("a reply answers the same whether or not the address already follows", async () => { const reader = new Browser(inst.app, "198.51.100.83"); const already = new Browser(inst.app, "198.51.100.84"); await send(already, "/app/issues/1/follow", { email: "kept@example.com" }, "/app/issues/1"); await inst.app.request(link(queued("kept@example.com")[0]?.body ?? "", "follow")); expect(queued("kept@example.com")).toHaveLength(1); // An address that already follows is told the same as one that does not, so a reply never // says who follows what (§23). Only the mail behind it differs. const follows = await send( reader, "/app/issues/1", { body: "Same here.", email: "kept@example.com" }, "/app/issues/1", ); const stranger = await send( reader, "/app/issues/1", { body: "New here.", email: "fresh@example.com" }, "/app/issues/1", ); expect(follows.headers.get("location")).toContain("?notice=check#entry-"); expect(stranger.headers.get("location")).toContain("?notice=check#entry-"); // The already-following address is sent no second confirmation; the answer is the same. const confirmations = (to: string) => queued(to).filter((row) => row.subject.startsWith("Confirm")); expect(confirmations("kept@example.com")).toHaveLength(1); expect(confirmations("fresh@example.com")).toHaveLength(1); }); test("a refused post does not echo more than a field may hold", async () => { const long = `${"x".repeat(20_100)}TAIL-MARK`; const reader = new Browser(inst.app, "198.51.100.82"); const refused = await send(reader, "/app/issues/1", { body: long }); expect(refused.status).toBe(400); const page = await refused.text(); expect(page).toContain("x".repeat(20_000)); expect(page).not.toContain("TAIL-MARK"); }); });