6662033dd2ca

wip: email follows and console thread for issues (v3.1.26); last gate 418 pass, 1 hook timeout under load, unverified

time
2026-10-08 19:01 UTC · 4 days ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
community issue threads
parent
0cb5291985c4
12 files package.json+1 −1public/css/kepr.css+49 −0src/admin/email.ts+9 −3src/admin/issues.ts+62 −16src/issues/followers.ts+125 −0src/issues/pages.ts+241 −35src/issues/spam.ts+11 −0src/issues/store.ts+28 −6src/server/app.ts+1 −1src/server/db.ts+62 −19src/server/mail.ts+40 −9tests/follow.test.ts+158 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.1.25",3+	"version": "3.1.26",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+49 −0
@@ -1923,6 +1923,55 @@19231923 	font-size: 0.75rem;19241924 	color: var(--text-faint);19251925 }1926+.issue-notice {1927+	margin: 18px 0 0;1928+	padding: 8px 14px;1929+	font-size: 0.75rem;1930+	color: var(--sage);1931+	background: var(--bg-surface);1932+	border-left: 2px solid var(--sage);1933+	border-radius: 0 4px 4px 0;1934+}1935+/* Name and email side by side; with email off, the name takes the whole row. */1936+.issue-composer-who {1937+	display: grid;1938+	grid-auto-flow: column;1939+	grid-auto-columns: 1fr;1940+	grid-template-rows: auto auto;1941+	gap: 6px 14px;1942+}1943+.issue-follow {1944+	display: flex;1945+	flex-direction: column;1946+	gap: 6px;1947+	margin: 14px 0 0 22px;1948+	padding: 12px 18px 14px;1949+	border: 1px dashed var(--border);1950+	border-radius: 6px;1951+	scroll-margin-top: 24px;1952+}1953+.issue-follow-row {1954+	display: flex;1955+	gap: 8px;1956+	margin: 0;1957+}1958+.issue-follow-row .issues-input {1959+	flex: 1;1960+}1961+.issue-follow-row .issues-submit {1962+	margin-top: 0;1963+}1964+.issues-check {1965+	display: flex;1966+	align-items: center;1967+	gap: 8px;1968+}1969+@media (max-width: 520px) {1970+	.issue-composer-who {1971+		grid-auto-flow: row;1972+		grid-template-rows: none;1973+	}1974+}19261975 .issue-attached {19271976 	display: flex;19281977 	flex-wrap: wrap;

view file

src/admin/email.ts+9 −3
@@ -11,6 +11,8 @@1111 type Queued = {1212 	id: number;1313 	subject: string;14+	/** '' for the operator's own address; else an issue follower's (§23). */15+	recipient: string;1416 	status: string;1517 	attempts: number;1618 	last_error: string;@@ -83,7 +85,10 @@8385 				}8486 				if (fields.action === "test") {8587 					try {86-						await sendMail(next, "test email", "Kepr can send you email.\n");88+						await sendMail(next, {89+							subject: "kepr: test email",90+							text: "Kepr can send you email.\n",91+						});8792 					} catch (error) {8893 						const error_ = `The test email was not sent: ${error instanceof Error ? error.message : error}`;8994 						return c.html(emailPage(c, ctx, { fields, error: error_ }), 400);@@ -151,7 +156,7 @@151156 	const security = value("security") || "starttls";152157 	const queue = ctx.kepr153158 		.query<Queued, [number]>(154-			`SELECT id, subject, status, attempts, last_error, created_at FROM email_queue159+			`SELECT id, subject, recipient, status, attempts, last_error, created_at FROM email_queue155160 			ORDER BY id DESC156161 			LIMIT ?`,157162 		)@@ -166,7 +171,8 @@166171 		email.attempts > 0167172 			? ` · ${email.attempts} ${email.attempts === 1 ? "attempt" : "attempts"}`168173 			: ""169-	}</span><span class="history-when">${relativeTime(email.created_at)}</span></p>174+	}${email.recipient && ` · to ${email.recipient}`}</span>175+<span class="history-when">${relativeTime(email.created_at)}</span></p>170176 <div class="issue-text">${email.subject}</div>171177 ${email.last_error && email.status !== "sent" && html`<p class="issues-error mono">${email.last_error}</p>`}172178 <div class="issues-actions">${email.status !== "sent" && action(email, "retry", "retry now")}

view file

src/admin/issues.ts+62 −16
@@ -1,4 +1,7 @@11 import type { Context, Hono } from "hono";2+import { issueAttachments } from "../issues/files.ts";3+import { followerCount, tellFollowers } from "../issues/followers.ts";4+import { attachmentsHtml } from "../issues/pages.ts";25 import { MAX_CHARS } from "../issues/spam.ts";36 import {47 	addEntry,@@ -13,6 +16,7 @@1316 	operatorEntries,1417 	operatorIssue,1518 	operatorIssues,19+	replyAddresses,1620 	reporterBlocked,1721 	setClosed,1822 	setEntryHidden,@@ -26,6 +30,7 @@2630 import { openMirror } from "../mirror/store.ts";2731 import { html } from "../pages/html.ts";2832 import { formatDate, relativeTime } from "../pages/layout.ts";33+import { renderComment } from "../pages/markdown.ts";2934 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";3035 import { allRepos } from "./store.ts";3136 @@ -42,6 +47,16 @@4247 4348 /** §24's Issues section: every repo's issues, and the operator's actions of §23. */4449 export function registerIssues(app: Hono, ctx: AdminContext): void {50+	// §23: followers hear of an issue's replies, closes and reopens, unless it is hidden.51+	const tell = (52+		issue: Pick<OperatorIssue, "id" | "repo" | "number" | "title" | "hidden">,53+		text: string,54+		anchor = "",55+	) => {56+		if (issue.hidden) return;57+		const href = `/${issue.repo}/issues/${issue.number}${anchor}`;58+		tellFollowers(ctx.kepr, `https://${ctx.domain}`, issue.repo, issue, { text, href });59+	};4560 	app.get("/admin/issues", (c) => {4661 		const filter = FILTERS.find(([f]) => f === c.req.query("filter"))?.[0] ?? "open";4762 		// An unknown repo or type narrows nothing, as the public list treats an unknown type.@@ -142,7 +157,9 @@142157 		switch (action) {143158 			case "close":144159 			case "reopen":145-				setManyClosed(ctx.kepr, ids, action === "close" ? unixNow() : null);160+				for (const changed of setManyClosed(ctx.kepr, ids, action === "close" ? unixNow() : null)) {161+					tell(changed, `"${changed.title}" was ${action === "close" ? "closed" : "reopened"}.`);162+				}146163 				break;147164 			case "hide":148165 			case "unhide":@@ -171,24 +188,23 @@171188 				if (body === "" || body.length > MAX_CHARS) {172189 					return refuse(`A reply is 1 to ${MAX_CHARS.toLocaleString("en-US")} characters.`);173190 				}174-				addEntry(ctx.kepr, issue.id, { author: "operator", body, at: unixNow() });191+				const entry = addEntry(ctx.kepr, issue.id, { author: "operator", body, at: unixNow() });192+				tell(issue, `The operator replied:\n\n${body}`, `#entry-${entry}`);175193 				break;176194 			}177195 			case "close": {178196 				const prefix = (fields.fixed_in ?? "").trim();179-				if (prefix === "") {180-					setClosed(ctx.kepr, issue.id, unixNow());181-					break;182-				}183197 				const save = SAVE_PREFIX.test(prefix)184198 					? saveByPrefix(openMirror(ctx.mirrorDir, issue.repo), prefix)185199 					: null;186-				if (!save) return refuse(`No save ${prefix} in ${issue.repo}.`);187-				setClosed(ctx.kepr, issue.id, unixNow(), save.id);200+				if (prefix !== "" && !save) return refuse(`No save ${prefix} in ${issue.repo}.`);201+				setClosed(ctx.kepr, issue.id, unixNow(), save?.id ?? "");202+				tell(issue, `"${issue.title}" was closed${save ? `, fixed in ${save.id}` : ""}.`);188203 				break;189204 			}190205 			case "reopen":191206 				setClosed(ctx.kepr, issue.id, null);207+				tell(issue, `"${issue.title}" was reopened.`);192208 				break;193209 			case "hide_entry":194210 			case "unhide_entry": {@@ -208,6 +224,16 @@208224 					return refuse("This issue has no address to block.");209225 				}210226 				break;227+			case "block_entry": {228+				const entry = Number(fields.entry);229+				if (230+					!Number.isSafeInteger(entry) ||231+					blockReporter(ctx.kepr, issue.id, unixNow(), entry) === null232+				) {233+					return refuse("This reply has no address to block.");234+				}235+				break;236+			}211237 			case "type":212238 				if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.");213239 				setType(ctx.kepr, issue.id, fields.type as IssueType);@@ -225,31 +251,51 @@225251 		html`<form method="post" action="${action}">226252 ${formField(c)}<input type="hidden" name="action" value="${name}">227253 <button class="issues-submit" type="submit">${label}</button></form>`;254+	const files = issueAttachments(ctx.kepr, issue.id);255+	const addresses = new Map(replyAddresses(ctx.kepr, issue.id).map((row) => [row.id, row.blocked]));256+	const entryAction = (entry: Entry, name: string, label: string) =>257+		html`<form method="post" action="${action}">${formField(c)}258+<input type="hidden" name="action" value="${name}">259+<input type="hidden" name="entry" value="${String(entry.id)}">260+<button class="issues-cancel mono" type="submit">${label}</button></form>`;228261 	const post = (operator: boolean, at: number, text: string, entry?: Entry) =>229262 		html`<article class="issue-post${operator ? " issue-post--operator" : ""}">230263 <p class="issue-post-head mono">${231264 			operator232265 				? html`<span class="issue-badge">operator</span>`233-				: html`<span class="issue-author">${entry ? entry.name || "anonymous" : issue.reporter_name || "anonymous"}</span>`266+				: html`<span class="issue-author">${267+						entry ? entry.name || "anonymous" : issue.reporter_name || "anonymous"268+					}</span>`234269 		}<span class="history-when">${formatDate(at)}</span>${235270 			entry?.hidden ? html`<span class="issues-error">hidden</span>` : ""236271 		}</p>237-<div class="issue-text">${text}</div>272+<div class="markdown issue-text">${renderComment(text)}</div>273+${attachmentsHtml(274+	`/${issue.repo}/issues/${issue.number}`,275+	files.filter((file) => file.entry_id === (entry?.id ?? null)),276+)}238277 ${239278 	entry &&240-	html`<form method="post" action="${action}">${formField(c)}241-<input type="hidden" name="action" value="${entry.hidden ? "unhide_entry" : "hide_entry"}">242-<input type="hidden" name="entry" value="${String(entry.id)}">243-<button class="issues-cancel mono" type="submit">${entry.hidden ? "unhide" : "hide (spam)"}</button>244-</form>`279+	html`<div class="issues-actions">280+${281+	entry.hidden282+		? entryAction(entry, "unhide_entry", "unhide")283+		: entryAction(entry, "hide_entry", "hide (spam)")245284 }285+${286+	addresses.get(entry.id) === 0287+		? entryAction(entry, "block_entry", "block this address")288+		: addresses.get(entry.id) === 1 && html`<span class="issues-error mono">address blocked</span>`289+}</div>`290+}246291 </article>`;247292 	const typeOption = (type: string) =>248293 		html`<option value="${type}"${issue.type === type ? html` selected` : ""}>${type}</option>`;249294 	const body = html`${notice(error)}250295 <p class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.status}${251296 		issue.fixed_in && ` · fixed in ${issue.fixed_in}`252-	}${issue.hidden ? " · hidden" : ""} · ${issue.version && `v${issue.version} · `}filed ${formatDate(issue.filed_at)}</p>297+	}${issue.hidden ? " · hidden" : ""} · ${issue.version && `v${issue.version} · `}filed ${formatDate(issue.filed_at)}298+· ${followerCount(ctx.kepr, issue.id)} following by email</p>253299 <p class="issue-meta mono">reporter: ${issue.reporter_name || "anonymous"}254300 · contact: ${issue.contact_email || "none given"}${255301 		reporterBlocked(ctx.kepr, issue.id)

view file

src/issues/followers.ts+125 −0
@@ -0,0 +1,125 @@1+import type { Database } from "bun:sqlite";2+import { queueEmail } from "../server/db.ts";3+import { followToken, followTokenMatches } from "./spam.ts";4+import type { Issue } from "./store.ts";5+6+// Enough to catch a typo or a stray name; the confirmation email is the real check.7+const EMAIL = /^[^\s@<>(),;:"]+@[^\s@<>(),;:"]+\.[^\s@<>(),;:"]+$/;8+9+export function isEmail(address: string): boolean {10+	return address.length <= 254 && EMAIL.test(address);11+}12+13+/**14+ * Asks an address to follow an issue (§23). A new one is sent one confirmation email and nothing15+ * more until it is confirmed; one already asked is sent nothing. True when a confirmation went out.16+ */17+export function follow(18+	kepr: Database,19+	site: string,20+	repo: string,21+	issue: Issue,22+	email: string,23+	ipHash: string,24+	now: number,25+): boolean {26+	const row = kepr27+		.query<{ id: number }, [number, string, string, number]>(28+			`INSERT INTO issue_followers (issue_id, email, ip_hash, created_at)29+VALUES (?, ?, ?, ?)30+ON CONFLICT (issue_id, email) DO NOTHING31+RETURNING id`,32+		)33+		.get(issue.id, email.toLowerCase(), ipHash, now);34+	if (!row) return false;35+	const token = followToken(kepr, row.id);36+	const base = `/${repo}/issues/${issue.number}`;37+	queueEmail(kepr, {38+		to: email.toLowerCase(),39+		subject: `Confirm email updates for ${repo} #${issue.number}`,40+		href: base,41+		text: `Someone asked for an email whenever "${issue.title}" (${repo} #${issue.number})42+gets a reply or is closed.43+44+To start them, open:45+${site}${base}/follow?f=${row.id}&t=${token}46+47+If that wasn't you, ignore this email and nothing more will come.48+`,49+	});50+	return true;51+}52+53+/** Confirms one follower of an issue when the link's token is theirs. */54+export function confirmFollower(kepr: Database, issueId: number, id: number, token: string) {55+	if (!followTokenMatches(kepr, id, token)) return false;56+	const changed = kepr57+		.query("UPDATE issue_followers SET confirmed = 1 WHERE id = ? AND issue_id = ?")58+		.run(id, issueId).changes;59+	return changed === 1;60+}61+62+/** Stops one follower's emails about an issue when the link's token is theirs. */63+export function unfollow(kepr: Database, issueId: number, id: number, token: string): boolean {64+	if (!followTokenMatches(kepr, id, token)) return false;65+	const changed = kepr66+		.query("DELETE FROM issue_followers WHERE id = ? AND issue_id = ?")67+		.run(id, issueId).changes;68+	return changed === 1;69+}70+71+/** Follow requests from one hashed IP since a time; they count toward the reply limit. */72+export function followsSince(kepr: Database, ipHash: string, since: number): number {73+	const row = kepr74+		.query<{ n: number }, [string, number]>(75+			"SELECT COUNT(*) AS n FROM issue_followers WHERE ip_hash = ? AND created_at > ?",76+		)77+		.get(ipHash, since);78+	return row?.n ?? 0;79+}80+81+export function followerCount(kepr: Database, issueId: number): number {82+	const row = kepr83+		.query<{ n: number }, [number]>(84+			"SELECT COUNT(*) AS n FROM issue_followers WHERE issue_id = ? AND confirmed = 1",85+		)86+		.get(issueId);87+	return row?.n ?? 0;88+}89+90+/**91+ * Emails an issue's confirmed followers (§23), except `except`, the address that caused the news.92+ * Each email ends with, and carries as its unsubscribe header, that follower's own stop link.93+ */94+export function tellFollowers(95+	kepr: Database,96+	site: string,97+	repo: string,98+	issue: Pick<Issue, "id" | "number" | "title">,99+	news: { text: string; href: string },100+	except = "",101+): void {102+	const followers = kepr103+		.query<{ id: number; email: string }, [number, string]>(104+			"SELECT id, email FROM issue_followers WHERE issue_id = ? AND confirmed = 1 AND email != ?",105+		)106+		.all(issue.id, except.toLowerCase());107+	kepr.transaction(() => {108+		for (const follower of followers) {109+			const token = followToken(kepr, follower.id);110+			const stop = `/${repo}/issues/${issue.number}/unfollow?f=${follower.id}&t=${token}`;111+			queueEmail(kepr, {112+				to: follower.email,113+				subject: `Re: ${issue.title} (${repo} #${issue.number})`,114+				href: news.href,115+				unsubscribe: stop,116+				text: `${news.text}117+118+${site}${news.href}119+120+You follow this issue by email. To stop: ${site}${stop}121+`,122+			});123+		}124+	})();125+}

view file

src/issues/pages.ts+241 −35
@@ -14,6 +14,7 @@1414 } from "../pages/layout.ts";1515 import { renderComment } from "../pages/markdown.ts";1616 import { visibleReleases } from "../releases/releases.ts";17+import type { Config } from "../server/config.ts";1718 import { notify } from "../server/db.ts";1819 import {1920 	type Attachment,@@ -28,8 +29,17 @@2829 	visibleFile,2930 } from "./files.ts";3031 import {32+	confirmFollower,33+	follow,34+	followsSince,35+	isEmail,36+	tellFollowers,37+	unfollow,38+} from "./followers.ts";39+import {3140 	checkStamp,3241 	FILINGS_PER_HOUR,42+	followTokenMatches,3343 	formStamp,3444 	ipHash,3545 	MAX_CHARS,@@ -52,18 +62,56 @@5262 } from "./store.ts";5363 5464 type Fields = Record<string, string>;55-// What a refused reply hands back to the composer, so nothing typed is lost.56-type Draft = { error?: string; body?: string; name?: string; replyTo?: number | null };65+// How a thread is shown: whether email is offered, a notice, and what a refused reply typed.66+type View = {67+	mail: boolean;68+	notice?: string;69+	/** Which form a refusal belongs to, so its error and what was typed show there. */70+	refused?: "reply" | "follow";71+	error?: string;72+	body?: string;73+	name?: string;74+	email?: string;75+	replyTo?: number | null;76+};5777 58-const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"];59-const REPLY_FIELDS = ["body", "name", "reply_to", "website", "stamp"];78+const FORM_FIELDS = [79+	"title",80+	"body",81+	"type",82+	"version",83+	"name",84+	"email",85+	"follow",86+	"website",87+	"stamp",88+];89+const REPLY_FIELDS = ["body", "name", "email", "reply_to", "website", "stamp"];6090 const NAME_CHARS = 200;6191 const unixNow = () => Math.floor(Date.now() / 1000);6292 // The same words whatever the reason, so a blocked address learns nothing more.6393 const BLOCKED = "This address can't post here.";94+const BAD_EMAIL = "That email address doesn't look right.";95+// What a thread says after a follow form or link, named by `?notice=`.96+const NOTICES: Record<string, string> = {97+	check: "Check your inbox: one email asks you to confirm, and replies follow once you do.",98+	following: "You'll get an email for each reply, and when the issue is closed or reopened.",99+	stopped: "You won't get any more emails about this issue.",100+	expired: "That link didn't work. You can follow again from this page.",101+};64102 65-/** §23: the list, the filing form, and the thread anyone can reply to. */66-export function registerIssuePages(app: Hono, ctx: PageContext): void {103+/** §23: the list, the filing form, and the thread anyone can reply to and follow by email. */104+export function registerIssuePages(app: Hono, ctx: PageContext, config: Config): void {105+	const mail = () => config.smtp !== undefined;106+	const site = () => `https://${ctx.domain}`;107+	const open = (c: Context) => {108+		const repo = openPublicRepo(ctx, c.req.param("repo") ?? "");109+		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));110+		return repo && issue111+			? { repo, issue, base: `/${repo.settings.name}/issues/${issue.number}` }112+			: null;113+	};114+67115 	app.get("/:repo/issues", (c) => {68116 		const repo = openPublicRepo(ctx, c.req.param("repo"));69117 		if (!repo) return notFound(c, ctx);@@ -76,17 +124,17 @@76124 		const repo = openPublicRepo(ctx, c.req.param("repo"));77125 		if (!repo) return notFound(c, ctx);78126 		const version = visibleReleases(ctx.kepr, repo.mirror, repo.settings.name)[0]?.version ?? "";79-		return formPage(c, ctx, repo, { type: "bug", version }, "", 200);127+		return formPage(c, ctx, repo, { type: "bug", version, follow: "1" }, "", 200, mail());80128 	});81129 82130 	app.post("/:repo/issues/new", async (c) => {83131 		const repo = openPublicRepo(ctx, c.req.param("repo"));84132 		if (!repo) return notFound(c, ctx);85-		if (repo.settings.issuesPaused) return formPage(c, ctx, repo, {}, "", 403);133+		if (repo.settings.issuesPaused) return formPage(c, ctx, repo, {}, "", 403, mail());86134 		const { fields, files } = await readForm(c, FORM_FIELDS);87135 		const now = unixNow();88136 		const refuse = (message: string, status: 400 | 403 | 429) =>89-			formPage(c, ctx, repo, fields, attachAgain(message, files), status);137+			formPage(c, ctx, repo, fields, attachAgain(message, files), status, mail());90138 		// A bot that fills the hidden field learns nothing from the answer.91139 		if (fields.website !== "") return refuse("This issue could not be filed.", 400);92140 		const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);@@ -94,8 +142,10 @@94142 		if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);95143 		const title = fields.title?.trim() ?? "";96144 		const body = fields.body?.trim() ?? "";145+		const email = fields.email?.trim() ?? "";97146 		if (title === "" || body === "") return refuse("A title and what happened are needed.", 400);98147 		if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.", 400);148+		if (email !== "" && !isEmail(email)) return refuse(BAD_EMAIL, 400);99149 		if (Object.values(fields).some((value) => value.length > MAX_CHARS)) {100150 			return refuse(101151 				`Please keep each field under ${MAX_CHARS.toLocaleString("en-US")} characters.`,@@ -117,7 +167,7 @@117167 			type: fields.type as IssueType,118168 			version: fields.version?.trim() ?? "",119169 			reporterName: fields.name?.trim() ?? "",120-			contactEmail: fields.email?.trim() ?? "",170+			contactEmail: email,121171 			ipHash: hash,122172 			filedAt: now,123173 		});@@ -128,7 +178,12 @@128178 			`New issue ${name} #${filed.number}: ${title}`,129179 			`/admin/issues/${name}/${filed.number}`,130180 		);131-		return c.redirect(`/${name}/issues/${filed.number}`, 303);181+		const issue = findIssue(ctx.kepr, name, filed.number);182+		const asked =183+			issue && mail() && fields.follow === "1" && email !== ""184+				? follow(ctx.kepr, site(), name, issue, email, hash, now)185+				: false;186+		return c.redirect(`/${name}/issues/${filed.number}${asked ? "?notice=check" : ""}`, 303);132187 	});133188 134189 	app.get("/:repo/issues/:n{[0-9]{1,9}}/files/:file{[0-9]{1,9}\\.[a-z]{3,4}}", (c) => {@@ -147,9 +202,9 @@147202 	});148203 149204 	app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => {150-		const repo = openPublicRepo(ctx, c.req.param("repo"));151-		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));152-		if (!repo || !issue) return notFound(c, ctx);205+		const found = open(c);206+		if (!found) return notFound(c, ctx);207+		const { repo, issue } = found;153208 		const entries = threadEntries(ctx.kepr, issue.id);154209 		// `?reply=<id>` answers a reply; `?quote=<id>` also quotes it, and 0 is the issue itself.155210 		const quoting = c.req.query("quote");@@ -159,6 +214,8 @@159214 		privatePage(c);160215 		return c.html(161216 			threadPage(c, ctx, repo, issue, entries, {217+				mail: mail(),218+				notice: NOTICES[c.req.query("notice") ?? ""],162219 				replyTo: target?.id ?? null,163220 				body: quoted ? `${quoted.replace(/^/gm, "> ")}\n\n` : "",164221 			}),@@ -166,20 +223,28 @@166223 	});167224 168225 	app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => {169-		const repo = openPublicRepo(ctx, c.req.param("repo"));170-		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));171-		if (!repo || !issue) return notFound(c, ctx);226+		const found = open(c);227+		if (!found) return notFound(c, ctx);228+		const { repo, issue, base } = found;172229 		const { fields, files } = await readForm(c, REPLY_FIELDS);173230 		const entries = threadEntries(ctx.kepr, issue.id);174-		const replyTo = entries.find((entry) => String(entry.id) === fields.reply_to)?.id ?? null;231+		const answering = entries.find((entry) => String(entry.id) === fields.reply_to);175232 		const body = fields.body?.trim() ?? "";176233 		const name = fields.name?.trim() ?? "";234+		const email = fields.email?.trim() ?? "";177235 		const now = unixNow();178236 		privatePage(c);179237 		const refuse = (message: string, status: 400 | 403 | 429) => {180-			const error = attachAgain(message, files);181-			const draft = { error, body: fields.body, name, replyTo };182-			return c.html(threadPage(c, ctx, repo, issue, entries, draft), status);238+			const view = {239+				mail: mail(),240+				refused: "reply" as const,241+				error: attachAgain(message, files),242+				body: fields.body,243+				name,244+				email,245+				replyTo: answering?.id ?? null,246+			};247+			return c.html(threadPage(c, ctx, repo, issue, entries, view), status);183248 		};184249 		if (fields.website !== "") return refuse("This reply could not be posted.", 400);185250 		if (issue.status === "closed") return refuse("This issue is closed to new replies.", 403);@@ -190,9 +255,12 @@190255 		if (body.length > MAX_CHARS || name.length > NAME_CHARS) {191256 			return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400);192257 		}258+		if (email !== "" && !isEmail(email)) return refuse(BAD_EMAIL, 400);193259 		const hash = ipHash(ctx.kepr, c);194260 		if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);195-		if (repliesSince(ctx.kepr, hash, now - 3600) >= REPLIES_PER_HOUR) {261+		const posted =262+			repliesSince(ctx.kepr, hash, now - 3600) + followsSince(ctx.kepr, hash, now - 3600);263+		if (posted >= REPLIES_PER_HOUR) {196264 			return refuse("Too many replies from here this hour. Please try again later.", 429);197265 		}198266 		const uploads = await checkUploads(ctx, files, hash, now);@@ -203,7 +271,7 @@203271 			body,204272 			at: now,205273 			ipHash: hash,206-			replyTo,274+			replyTo: answering?.id ?? null,207275 		});208276 		attachFiles(ctx.kepr, issue.id, entry, uploads, hash, now);209277 		notify(@@ -212,8 +280,112 @@212280 			`Reply on ${repo.settings.name} #${issue.number}: ${issue.title}`,213281 			`/admin/issues/${repo.settings.name}/${issue.number}`,214282 		);215-		return c.redirect(`/${repo.settings.name}/issues/${issue.number}#entry-${entry}`, 303);283+		const author = name || "anonymous";284+		const to = answering285+			? ` to ${answering.author === "operator" ? "the operator" : answering.name || "anonymous"}`286+			: "";287+		const extra = uploads.length > 0 ? `\n\n(${uploads.length} attached on the page)` : "";288+		tellFollowers(289+			ctx.kepr,290+			site(),291+			repo.settings.name,292+			issue,293+			{ text: `${author} replied${to}:\n\n${body}${extra}`, href: `${base}#entry-${entry}` },294+			email,295+		);296+		const asked =297+			mail() &&298+			email !== "" &&299+			follow(ctx.kepr, site(), repo.settings.name, issue, email, hash, now);300+		return c.redirect(`${base}${asked ? "?notice=check" : ""}#entry-${entry}`, 303);216301 	});302+303+	app.post("/:repo/issues/:n{[0-9]{1,9}}/follow", async (c) => {304+		const found = open(c);305+		if (!found || !mail()) return notFound(c, ctx);306+		const { repo, issue, base } = found;307+		const { fields } = await readForm(c, ["email", "website", "stamp"]);308+		const email = fields.email?.trim() ?? "";309+		const now = unixNow();310+		privatePage(c);311+		const refuse = (error: string, status: 400 | 403 | 429) => {312+			const view = { mail: true, refused: "follow" as const, error, email };313+			const entries = threadEntries(ctx.kepr, issue.id);314+			return c.html(threadPage(c, ctx, repo, issue, entries, view), status);315+		};316+		if (fields.website !== "") return refuse("This address could not follow.", 400);317+		const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);318+		if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);319+		if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);320+		if (!isEmail(email)) return refuse(BAD_EMAIL, 400);321+		const hash = ipHash(ctx.kepr, c);322+		if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);323+		const posted =324+			repliesSince(ctx.kepr, hash, now - 3600) + followsSince(ctx.kepr, hash, now - 3600);325+		if (posted >= REPLIES_PER_HOUR) {326+			return refuse("Too many requests from here this hour. Please try again later.", 429);327+		}328+		follow(ctx.kepr, site(), repo.settings.name, issue, email, hash, now);329+		// The same answer whether the address was new, so a page never says who follows what; it330+		// opens at the top, where the notice is.331+		return c.redirect(`${base}?notice=check`, 303);332+	});333+334+	// The link in a confirmation email (§23); its token is the proof the inbox is theirs.335+	app.get("/:repo/issues/:n{[0-9]{1,9}}/follow", (c) => {336+		const found = open(c);337+		if (!found) return notFound(c, ctx);338+		const confirmed = confirmFollower(339+			ctx.kepr,340+			found.issue.id,341+			Number(c.req.query("f")),342+			c.req.query("t") ?? "",343+		);344+		privatePage(c);345+		return c.redirect(`${found.base}?notice=${confirmed ? "following" : "expired"}`, 303);346+	});347+348+	// A stop link asks first, so a mail scanner opening it changes nothing.349+	app.get("/:repo/issues/:n{[0-9]{1,9}}/unfollow", (c) => {350+		const found = open(c);351+		if (!found) return notFound(c, ctx);352+		const { repo, issue, base } = found;353+		const id = Number(c.req.query("f"));354+		const token = c.req.query("t") ?? "";355+		privatePage(c);356+		if (!followTokenMatches(ctx.kepr, id, token)) return c.redirect(`${base}?notice=expired`, 303);357+		const query = new URLSearchParams({ f: String(id), t: token });358+		return c.html(359+			page(ctx, {360+				title: `stop emails · #${issue.number} ${issue.title}`,361+				description: "",362+				path: `${base}/unfollow`,363+				body: html`${crumbs(repo.settings.name)}364+<h1 class="file-page-title">stop emails about #${issue.number}?</h1>365+<p>You follow “${issue.title}” by email. Stopping takes effect at once.</p>366+<form class="issues-actions" method="post" action="${base}/unfollow?${query.toString()}">367+<button class="issues-submit" type="submit">stop emails</button>368+<a class="issues-cancel mono" href="${base}">keep them</a></form>`,369+			}),370+		);371+	});372+373+	// Both the page's button and an inbox's one-click unsubscribe (RFC 8058) post here.374+	app.post("/:repo/issues/:n{[0-9]{1,9}}/unfollow", async (c) => {375+		const found = open(c);376+		if (!found) return notFound(c, ctx);377+		const stopped = unfollow(378+			ctx.kepr,379+			found.issue.id,380+			Number(c.req.query("f")),381+			c.req.query("t") ?? "",382+		);383+		const form = await c.req.parseBody();384+		if (form["List-Unsubscribe"] === "One-Click") {385+			return c.text(stopped ? "Unsubscribed." : "Unknown link.", stopped ? 200 : 404);386+		}387+		return c.redirect(`${found.base}?notice=${stopped ? "stopped" : "expired"}`, 303);388+	});217389 }218390 219391 async function readForm(c: Context, names: string[]): Promise<{ fields: Fields; files: File[] }> {@@ -254,7 +426,7 @@254426 	return files.length > 0 ? `${message} Please attach your files again.` : message;255427 }256428 257-// A page carrying a form stamp is never cached, and its URL never leaks as a referrer.429+// A page carrying a form stamp or a follow link is never cached, nor sent on as a referrer.258430 function privatePage(c: Context): void {259431 	c.header("Cache-Control", "no-store");260432 	c.header("Referrer-Policy", "no-referrer");@@ -330,6 +502,7 @@330502 	values: Fields,331503 	error: string,332504 	status: 200 | 400 | 403 | 429,505+	mail: boolean,333506 ): Response {334507 	const name = repo.settings.name;335508 	privatePage(c);@@ -365,7 +538,14 @@365538 autocomplete="nickname" value="${values.name ?? ""}">366539 <label class="issues-label" for="issue-email">contact email367540 <span class="muted">(optional, only the operator sees it)</span></label>368-<input class="issues-input" id="issue-email" name="email" type="email" maxlength="320" value="${values.email ?? ""}">541+<input class="issues-input" id="issue-email" name="email" type="email" maxlength="254"542+autocomplete="email" value="${values.email ?? ""}">543+${544+	mail &&545+	html`<label class="issues-label issues-check">546+<input type="checkbox" name="follow" value="1"${values.follow === "1" ? html` checked` : ""}>547+email me each reply, and when it's closed</label>`548+}369549 <p class="issues-actions"><button class="issues-submit" type="submit">file issue</button>370550 <a class="issues-cancel mono" href="/${name}/issues">cancel</a></p>371551 </form>`;@@ -424,7 +604,7 @@424604 	repo: PublicRepo,425605 	issue: Issue,426606 	entries: Entry[],427-	draft: Draft,607+	view: View,428608 ): string {429609 	const name = repo.settings.name;430610 	const base = `/${name}/issues/${issue.number}`;@@ -432,8 +612,9 @@432612 	const who = (entry: Entry) =>433613 		entry.author === "operator" ? "operator" : entry.name || "anonymous";434614 	const byId = new Map(entries.map((entry) => [entry.id, entry]));435-	const answering = draft.replyTo ? byId.get(draft.replyTo) : undefined;615+	const answering = view.replyTo ? byId.get(view.replyTo) : undefined;436616 	const files = issueAttachments(ctx.kepr, issue.id);617+	const stamp = formStamp(ctx.kepr, c, unixNow());437618 	// The issue itself is post 0: quoting it quotes what was filed.438619 	const post = (key: number, author: string, operator: boolean, at: number, body: string) => {439620 		const id = key === 0 ? "issue" : `entry-${key}`;@@ -461,6 +642,10 @@461642 </article>`;462643 	};463644 	const reporter = issue.reporter_name || "anonymous";645+	const error = view.error && html`<p class="issues-error mono" role="alert">${view.error}</p>`;646+	const honeypot = html`<input type="hidden" name="stamp" value="${stamp}">647+<label class="issues-hp" aria-hidden="true">leave this empty648+<input name="website" tabindex="-1" autocomplete="off"></label>`;464649 	return page(ctx, {465650 		title: `#${issue.number} ${issue.title} · ${name}`,466651 		description: issue.title,@@ -473,6 +658,7 @@473658 · filed by ${reporter} ${relativeTime(issue.filed_at)}474659 ${entries.length > 0 && ` · ${entries.length} ${entries.length === 1 ? "reply" : "replies"}`}475660 ${!open && fixedIn(repo, issue)}</p>661+${view.notice && html`<p class="issue-notice mono" role="status">${view.notice}</p>`}476662 <div class="issue-thread">477663 ${post(0, reporter, false, issue.filed_at, issue.body)}478664 ${entries.map((entry) =>@@ -483,9 +669,7 @@483669 	open484670 		? html`<form class="issue-composer" id="reply" method="post" action="${base}"485671 enctype="multipart/form-data">486-<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">487-<label class="issues-hp" aria-hidden="true">leave this empty488-<input name="website" tabindex="-1" autocomplete="off"></label>672+${honeypot}489673 <input type="hidden" name="reply_to" value="${answering ? String(answering.id) : ""}">490674 <p class="issue-composer-head mono"><label for="reply-body">add a reply</label>491675 <span class="issue-composer-to"${answering ? "" : html` hidden`}>replying to@@ -493,20 +677,42 @@493677 href="#entry-${answering ? String(answering.id) : ""}">${answering ? who(answering) : ""}</a>494678 <a class="issues-cancel" href="${base}#reply"495679 aria-label="reply to the issue instead">×</a></span></p>496-${draft.error && html`<p class="issues-error mono" role="alert">${draft.error}</p>`}680+${view.refused === "reply" && error}497681 <textarea class="issues-textarea" id="reply-body" name="body" rows="7" required498682 maxlength="${MAX_CHARS}" placeholder="More detail, another angle, what you tried…"499->${draft.body ?? ""}</textarea>683+>${view.body ?? ""}</textarea>500684 ${markdownHint}501685 ${attachField("reply-files")}686+<div class="issue-composer-who">502687 <label class="issues-label" for="reply-name">name to show503688 <span class="muted">(optional)</span></label>504689 <input class="issues-input" id="reply-name" name="name" maxlength="${NAME_CHARS}"505-autocomplete="nickname" value="${draft.name ?? ""}">690+autocomplete="nickname" value="${view.name ?? ""}">691+${692+	view.mail &&693+	html`<label class="issues-label" for="reply-email">email me new replies694+<span class="muted">(optional, never shown)</span></label>695+<input class="issues-input" id="reply-email" name="email" type="email" maxlength="254"696+autocomplete="email" value="${view.refused === "reply" ? (view.email ?? "") : ""}">`697+}698+</div>506699 <p class="issues-actions"><button class="issues-submit" type="submit">post reply</button></p>507700 </form>`508-		: html`${draft.error && html`<p class="issues-error mono" role="alert">${draft.error}</p>`}701+		: html`${view.refused === "reply" && error}509702 <p class="issue-closed-note mono">This issue is closed, so it takes no new replies.</p>`703+}704+${705+	view.mail &&706+	html`<form class="issue-follow" id="follow" method="post" action="${base}/follow">707+${honeypot}708+<label class="issues-label" for="follow-email">follow by email709+<span class="muted">(each reply, and when it closes; your address is never shown)</span></label>710+${view.refused === "follow" && error}711+<p class="issue-follow-row"><input class="issues-input" id="follow-email" name="email" type="email"712+required maxlength="254" autocomplete="email" placeholder="[email protected]"713+value="${view.refused === "follow" ? (view.email ?? "") : ""}">714+<button class="issues-submit" type="submit">follow</button></p>715+</form>`510716 }`,511717 	});512718 }

view file

src/issues/spam.ts+11 −0
@@ -31,6 +31,17 @@3131 	return mac(db, `ip:${clientIp(c)}`);3232 }3333 34+/** What a follower's confirm and stop links carry (§23): only their inbox ever sees it. */35+export function followToken(db: Database, followerId: number): string {36+	return mac(db, `follow:${followerId}`);37+}38+39+export function followTokenMatches(db: Database, followerId: number, token: string): boolean {40+	const expected = Buffer.from(followToken(db, followerId));41+	const given = Buffer.from(token);42+	return given.length === expected.length && timingSafeEqual(given, expected);43+}44+3445 /**3546  * SameSite=Strict so a cross-site post arrives without it, and a form stamp3647  * `<unix time>.<HMAC(time, cookie)>`.

view file

src/issues/store.ts+28 −6
@@ -291,14 +291,36 @@291291 292292 /**293293  * Closes or reopens many issues at once (§24). Only those not already so change, so a closed294- * issue keeps its time and "fixed in" save.294+ * issue keeps its time and "fixed in" save; those that changed are returned.295295  */296-export function setManyClosed(db: Database, ids: number[], closedAt: number | null): void {297-	db.query(298-		`UPDATE issues296+export function setManyClosed(297+	db: Database,298+	ids: number[],299+	closedAt: number | null,300+): Pick<OperatorIssue, "id" | "repo" | "number" | "title" | "hidden">[] {301+	return db302+		.query<303+			Pick<OperatorIssue, "id" | "repo" | "number" | "title" | "hidden">,304+			[string, number | null, string]305+		>(306+			`UPDATE issues299307 		SET status = ?1, closed_at = ?2, fixed_in = ''300-		WHERE id IN (SELECT value FROM json_each(?3)) AND status != ?1`,301-	).run(closedAt === null ? "open" : "closed", closedAt, JSON.stringify(ids));308+		WHERE id IN (SELECT value FROM json_each(?3)) AND status != ?1309+		RETURNING id, repo, number, title, hidden`,310+		)311+		.all(closedAt === null ? "open" : "closed", closedAt, JSON.stringify(ids));312+}313+314+/** Each reply of an issue that came with an address, and whether that address is blocked. */315+export function replyAddresses(db: Database, issueId: number): { id: number; blocked: number }[] {316+	return db317+		.query<{ id: number; blocked: number }, [number]>(318+			`SELECT e.id, b.id IS NOT NULL AS blocked319+FROM issue_entries e320+LEFT JOIN blocked_reporters b ON b.ip_hash = e.ip_hash321+WHERE e.issue_id = ? AND e.ip_hash != ''`,322+		)323+		.all(issueId);302324 }303325 304326 export function setManyHidden(db: Database, ids: number[], hidden: boolean): void {

view file

src/server/app.ts+1 −1
@@ -210,7 +210,7 @@210210 	registerKohRemote(app, ctx);211211 	registerArchivesPage(app, ctx);212212 	registerReleasesPage(app, ctx);213-	registerIssuePages(app, ctx);213+	registerIssuePages(app, ctx, config);214214 	registerTarballRoutes(app, ctx, bucket);215215 	registerReleaseRoutes(app, ctx, bucket);216216 	return app;

view file

src/server/db.ts+62 −19
@@ -134,18 +134,33 @@134134 	created_at INTEGER NOT NULL,135135 	read       INTEGER NOT NULL DEFAULT 0136136 );137--- §24: notifications on their way to the operator's inbox; the address is kepr.toml's.137+-- §24: emails on their way out. An empty recipient is the operator, kepr.toml's address, sent the138+-- subject and the link; an issue's follower (§23) is sent the body, unsubscribe as its header.138139 CREATE TABLE IF NOT EXISTS email_queue (140+	id          INTEGER PRIMARY KEY,141+	subject     TEXT NOT NULL,142+	href        TEXT NOT NULL,143+	recipient   TEXT NOT NULL DEFAULT '',144+	body        TEXT NOT NULL DEFAULT '',145+	unsubscribe TEXT NOT NULL DEFAULT '',146+	status      TEXT NOT NULL DEFAULT 'waiting' CHECK (status IN ('waiting', 'sent', 'failed')),147+	attempts    INTEGER NOT NULL DEFAULT 0,148+	next_at     INTEGER NOT NULL,149+	last_error  TEXT NOT NULL DEFAULT '',150+	created_at  INTEGER NOT NULL,151+	sent_at     INTEGER152+);153+-- §23: addresses following an issue by email; nothing is sent to one until it is confirmed.154+CREATE TABLE IF NOT EXISTS issue_followers (139155 	id         INTEGER PRIMARY KEY,140-	subject    TEXT NOT NULL,141-	href       TEXT NOT NULL,142-	status     TEXT NOT NULL DEFAULT 'waiting' CHECK (status IN ('waiting', 'sent', 'failed')),143-	attempts   INTEGER NOT NULL DEFAULT 0,144-	next_at    INTEGER NOT NULL,145-	last_error TEXT NOT NULL DEFAULT '',156+	issue_id   INTEGER NOT NULL REFERENCES issues (id),157+	email      TEXT NOT NULL,158+	confirmed  INTEGER NOT NULL DEFAULT 0,159+	ip_hash    TEXT NOT NULL DEFAULT '',146160 	created_at INTEGER NOT NULL,147-	sent_at    INTEGER161+	UNIQUE (issue_id, email)148162 );163+CREATE INDEX IF NOT EXISTS issue_followers_ip ON issue_followers (ip_hash, created_at);149164 -- A repo renamed from the console (June, 2026-10-01): its old pages redirect to the new name.150165 CREATE TABLE IF NOT EXISTS renamed (151166 	old TEXT PRIMARY KEY,@@ -173,6 +188,9 @@173188 	["issue_entries", "reply_to", "INTEGER"],174189 	["repos", "hidden_paths", "TEXT NOT NULL DEFAULT ''"],175190 	["repos", "hidden_handed", "TEXT"],191+	["email_queue", "recipient", "TEXT NOT NULL DEFAULT ''"],192+	["email_queue", "body", "TEXT NOT NULL DEFAULT ''"],193+	["email_queue", "unsubscribe", "TEXT NOT NULL DEFAULT ''"],176194 ];177195 // Columns an older kepr.db still has; card order and install lines were removed (June, 2026-09-30).178196 const DROPPED_COLUMNS: [table: string, column: string][] = [@@ -266,7 +284,7 @@266284 267285 /**268286  * Tells the operator, unless that kind is switched off, and queues an email when that kind is269- * emailed. The newest 200 of each are kept.287+ * emailed. The newest 200 notifications are kept.270288  */271289 export function notify(db: Database, kind: NotificationKind, text: string, href: string): void {272290 	const now = Math.floor(Date.now() / 1000);@@ -279,20 +297,45 @@279297 				href,280298 				now,281299 			);282-		}283-		if (setting(db, `email_on_${kind}`)) {284300 			db.query(285-				"INSERT INTO email_queue (subject, href, next_at, created_at) VALUES (?, ?, ?, ?)",286-			).run(text, href, now, now);287-		}288-		for (const table of ["notifications", "email_queue"]) {289-			// `table` is one of the two names above, never input.290-			db.query(291-				`DELETE FROM ${table}292-				WHERE id NOT IN (SELECT id FROM ${table} ORDER BY id DESC LIMIT ?)`,301+				`DELETE FROM notifications302+				WHERE id NOT IN (SELECT id FROM notifications ORDER BY id DESC LIMIT ?)`,293303 			).run(NOTIFICATIONS_KEPT);294304 		}305+		if (setting(db, `email_on_${kind}`)) queueEmail(db, { subject: text, href });295306 	}).immediate();307+}308+309+export type Email = {310+	subject: string;311+	href: string;312+	to?: string;313+	text?: string;314+	unsubscribe?: string;315+};316+317+/**318+ * Queues an email (§24): to the operator, or to `to` with `text` as its body. Only the newest 200319+ * are kept, and never by dropping one still waiting to go.320+ */321+export function queueEmail(db: Database, email: Email): void {322+	const now = Math.floor(Date.now() / 1000);323+	db.query(324+		`INSERT INTO email_queue (subject, href, recipient, body, unsubscribe, next_at, created_at)325+		VALUES (?, ?, ?, ?, ?, ?, ?)`,326+	).run(327+		email.subject,328+		email.href,329+		email.to ?? "",330+		email.text ?? "",331+		email.unsubscribe ?? "",332+		now,333+		now,334+	);335+	db.query(336+		`DELETE FROM email_queue337+		WHERE status != 'waiting' AND id NOT IN (SELECT id FROM email_queue ORDER BY id DESC LIMIT ?)`,338+	).run(NOTIFICATIONS_KEPT);296339 }297340 298341 /** Records a sync's outcome; a repo that starts failing tells the operator, once. */

view file

src/server/mail.ts+40 −9
@@ -7,8 +7,11 @@77 const RETRY_MINUTES = [1, 5, 30, 120, 720];88 const TIMEOUT_MS = 15 * 1000;99 10-/** Sends one plain-text email to the operator, now; throws with the server's answer on failure. */11-export async function sendMail(smtp: SmtpSettings, subject: string, text: string): Promise<void> {10+/** One plain-text email; to the operator unless `to` names someone else. */11+export type Mail = { subject: string; text: string; to?: string; unsubscribe?: string };12+13+/** Sends one email now; throws with the server's answer on failure. */14+export async function sendMail(smtp: SmtpSettings, mail: Mail): Promise<void> {1215 	const transport = createTransport({1316 		host: smtp.host,1417 		port: smtp.port,@@ -23,17 +26,32 @@2326 	try {2427 		await transport.sendMail({2528 			from: smtp.from,26-			to: smtp.to,29+			to: mail.to ?? smtp.to,2730 			// Issue titles come from strangers: one line, and short enough for any inbox.28-			subject: `kepr: ${subject}`.replace(/[\r\n]+/g, " ").slice(0, 200),29-			text,31+			subject: mail.subject.replace(/[\r\n]+/g, " ").slice(0, 200),32+			text: mail.text,33+			// RFC 8058's one-click unsubscribe, which inboxes show as their own button.34+			...(mail.unsubscribe && {35+				headers: {36+					"List-Unsubscribe": `<${mail.unsubscribe}>`,37+					"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",38+				},39+			}),3040 		});3141 	} finally {3242 		transport.close();3343 	}3444 }3545 36-type Queued = { id: number; subject: string; href: string; attempts: number };46+type Queued = {47+	id: number;48+	subject: string;49+	href: string;50+	recipient: string;51+	body: string;52+	unsubscribe: string;53+	attempts: number;54+};3755 3856 let running: Promise<void> | null = null;3957 @@ -48,7 +66,7 @@4866 async function deliver(kepr: Database, config: Config, now: number): Promise<void> {4967 	const due = kepr5068 		.query<Queued, [number]>(51-			`SELECT id, subject, href, attempts FROM email_queue69+			`SELECT id, subject, href, recipient, body, unsubscribe, attempts FROM email_queue5270 			WHERE status = 'waiting' AND next_at <= ?5371 			ORDER BY id`,5472 		)@@ -56,8 +74,21 @@5674 	for (const email of due) {5775 		try {5876 			if (!config.smtp) throw new Error("no [smtp] settings in kepr.toml");59-			const link = `https://${config.domain}${email.href}`;60-			await sendMail(config.smtp, email.subject, `${email.subject}\n\n${link}\n`);77+			const site = `https://${config.domain}`;78+			await sendMail(79+				config.smtp,80+				email.recipient81+					? {82+							to: email.recipient,83+							subject: email.subject,84+							text: email.body,85+							unsubscribe: email.unsubscribe && `${site}${email.unsubscribe}`,86+						}87+					: {88+							subject: `kepr: ${email.subject}`,89+							text: `${email.subject}\n\n${site}${email.href}\n`,90+						},91+			);6192 			kepr6293 				.query("UPDATE email_queue SET status = 'sent', attempts = ?, sent_at = ? WHERE id = ?")6394 				.run(email.attempts + 1, now, email.id);

view file

tests/follow.test.ts+158 −0
@@ -0,0 +1,158 @@1+// Covers §23's email updates: confirming before anything is sent, who is told what, stopping by2+// link or one-click, and nothing offered without SMTP.3+4+import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";5+import { Browser, type Instance, instance } from "./browser.ts";6+import { makeRepos } from "./fixture.ts";7+8+let clock = Date.parse("2026-10-08T12:00:00Z");9+const tick = (seconds: number) => {10+	clock += seconds * 1000;11+	setSystemTime(new Date(clock));12+};13+type Queued = { recipient: string; subject: string; body: string; unsubscribe: string };14+15+describe("following an issue by email (§23)", () => {16+	let inst: Instance;17+	let operator: Browser;18+	const queued = (to: string) =>19+		inst.kepr20+			.query<Queued, [string]>(21+				"SELECT recipient, subject, body, unsubscribe FROM email_queue WHERE recipient = ? ORDER BY id",22+			)23+			.all(to);24+	const stamped = async (who: Browser, path: string) => {25+		const page = await (await who.request(path)).text();26+		tick(4);27+		return /name="stamp" value="([^"]+)"/.exec(page)?.[1] ?? "";28+	};29+	const send = async (who: Browser, path: string, fields: Record<string, string>, from = path) =>30+		who.request(path, {31+			method: "POST",32+			body: new URLSearchParams({ website: "", stamp: await stamped(who, from), ...fields }),33+		});34+	const link = (body: string, action: string) =>35+		new RegExp(`https://kepr\\.example(/app/issues/1/${action}\\?f=\\d+&t=[0-9a-f]+)`).exec(36+			body,37+		)?.[1] ?? "";38+39+	beforeAll(async () => {40+		setSystemTime(new Date(clock));41+		inst = await instance((await makeRepos(["app"])).bucketDir);42+		operator = new Browser(inst.app);43+		await operator.signIn();44+		await operator.post("/admin/repos/app", { public: "1" });45+		const reporter = new Browser(inst.app, "198.51.100.60");46+		await send(reporter, "/app/issues/new", { title: "Crash", body: "It broke.", type: "bug" });47+	});48+	afterAll(() => setSystemTime());49+50+	test("without SMTP, nothing offers email and following is not there", async () => {51+		const thread = await (await inst.app.request("/app/issues/1")).text();52+		expect(thread).not.toContain('id="follow"');53+		expect(thread).not.toContain('name="email"');54+		const reader = new Browser(inst.app, "198.51.100.61");55+		const response = await send(56+			reader,57+			"/app/issues/1/follow",58+			{ email: "[email protected]" },59+			"/app/issues/1",60+		);61+		expect(response.status).toBe(404);62+	});63+64+	test("an address gets one confirmation, and nothing else until it confirms", async () => {65+		inst.config.smtp = {66+			host: "smtp.example",67+			port: 587,68+			security: "starttls",69+			username: "",70+			password: "",71+			from: "[email protected]",72+			to: "[email protected]",73+		};74+		const reader = new Browser(inst.app, "198.51.100.61");75+		const thread = await (await reader.request("/app/issues/1")).text();76+		expect(thread).toContain('id="follow"');77+		const follow = () =>78+			send(reader, "/app/issues/1/follow", { email: "[email protected]" }, "/app/issues/1");79+		const response = await follow();80+		expect(response.status).toBe(303);81+		expect(response.headers.get("location")).toBe("/app/issues/1?notice=check");82+		expect((await follow()).headers.get("location")).toBe("/app/issues/1?notice=check");83+		const [confirm, ...more] = queued("[email protected]");84+		expect(more).toEqual([]);85+		expect(confirm?.subject).toBe("Confirm email updates for app #1");86+		expect(confirm?.unsubscribe).toBe("");87+		const other = new Browser(inst.app, "198.51.100.62");88+		await send(other, "/app/issues/1", { body: "Me too." });89+		expect(queued("[email protected]")).toHaveLength(1);90+		const wrong = await inst.app.request(91+			link(confirm?.body ?? "", "follow").replace(/t=\w+/, "t=00"),92+		);93+		expect(wrong.headers.get("location")).toBe("/app/issues/1?notice=expired");94+		const opened = await inst.app.request(link(confirm?.body ?? "", "follow"));95+		expect(opened.headers.get("location")).toBe("/app/issues/1?notice=following");96+		expect(await (await inst.app.request("/app/issues/1?notice=following")).text()).toContain(97+			"You&#39;ll get an email for each reply",98+		);99+	});100+101+	test("followers hear of each reply but their own, with a stop link and header", async () => {102+		const other = new Browser(inst.app, "198.51.100.62");103+		await send(other, "/app/issues/1", { body: "Still <broken>.", name: "Bo" });104+		const [, reply] = queued("[email protected]");105+		expect(reply?.subject).toBe("Re: Crash (app #1)");106+		expect(reply?.body).toContain("Bo replied:\n\nStill <broken>.");107+		expect(reply?.body).toContain("https://kepr.example/app/issues/1#entry-");108+		expect(reply?.unsubscribe).toMatch(/^\/app\/issues\/1\/unfollow\?f=\d+&t=[0-9a-f]+$/);109+		const ada = new Browser(inst.app, "198.51.100.61");110+		await send(ada, "/app/issues/1", { body: "Thanks.", email: "[email protected]" });111+		expect(queued("[email protected]")).toHaveLength(2);112+	});113+114+	test("the operator's reply and close reach followers too", async () => {115+		await operator.get("/admin/issues/app/1");116+		await operator.post("/admin/issues/app/1", { action: "reply", body: "Looking into it." });117+		await operator.post("/admin/issues/app/1", { action: "close", fixed_in: "" });118+		const [, , reply, closed] = queued("[email protected]");119+		expect(reply?.body).toContain("The operator replied:\n\nLooking into it.");120+		expect(closed?.body).toContain('"Crash" was closed.');121+	});122+123+	test("a stop link asks first, and an inbox's one-click stops at once", async () => {124+		const stop = queued("[email protected]")[1]?.unsubscribe ?? "";125+		const page = await (await inst.app.request(stop)).text();126+		expect(page).toContain("stop emails");127+		expect(queued("[email protected]")).toHaveLength(4);128+		const oneClick = await inst.app.request(stop, {129+			method: "POST",130+			body: new URLSearchParams({ "List-Unsubscribe": "One-Click" }),131+		});132+		expect(oneClick.status).toBe(200);133+		await operator.post("/admin/issues/app/1", { action: "reopen" });134+		expect(queued("[email protected]")).toHaveLength(4);135+		const again = await inst.app.request(stop, { method: "POST" });136+		expect(again.headers.get("location")).toBe("/app/issues/1?notice=expired");137+	});138+139+	test("filing can follow the issue at the contact address", async () => {140+		const reporter = new Browser(inst.app, "198.51.100.63");141+		const filed = await send(reporter, "/app/issues/new", {142+			title: "Dark mode",143+			body: "Please.",144+			type: "idea",145+			email: "[email protected]",146+			follow: "1",147+		});148+		expect(filed.headers.get("location")).toBe("/app/issues/2?notice=check");149+		expect(queued("[email protected]")[0]?.subject).toBe("Confirm email updates for app #2");150+		const bad = await send(reporter, "/app/issues/new", {151+			title: "x",152+			body: "y",153+			type: "bug",154+			email: "not an address",155+		});156+		expect(bad.status).toBe(400);157+	});158+});

view file