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
↓ kepr-6662033d.tar.gzatlas at this save
files changed12 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 −0package.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": {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;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")}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)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+}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 }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)>`.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 {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;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. */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);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'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+});