301147eeaa38

email notifications over SMTP with a test send and a retrying delivery queue (v3.0.137)

time
2026-09-29 22:41 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
email delivery
parent
2d3ee7d12177
12 files bun.lock+3 −0package.json+2 −1public/css/kepr.css+4 −0src/admin/console.ts+1 −0src/admin/email.ts+215 −0src/admin/notifications.ts+1 −1src/main.ts+2 −0src/server/app.ts+2 −0src/server/config.ts+49 −0src/server/db.ts+37 −12src/server/mail.ts+87 −0tests/email.test.ts+254 −0
bun.lock+3 −0
@@ -8,6 +8,7 @@88         "@noble/hashes": "^2.4.0",99         "hono": "^4.13.9",1010         "markdown-it": "^15.0.2",11+        "nodemailer": "^10.0.12",1112         "shiki": "^4.4.3",1213       },1314       "devDependencies": {@@ -156,6 +157,8 @@156157     "micromark-util-symbol": ["[email protected]", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],157158 158159     "micromark-util-types": ["[email protected]", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="],160+161+    "nodemailer": ["[email protected]", "", {}, "sha512-PQ46oNbNMuH/Sno7B5IWIU9etytwrO0xdTFvWXpIQFRXhBAXtzD48243AS+BCbQQTPD8y3fqUrgLn0tSo77gvg=="],159162 160163     "oniguruma-parser": ["[email protected]", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],161164 

view file

package.json+2 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.136",3+	"version": "3.0.137",44 	"private": true,55 	"type": "module",66 	"scripts": {@@ -13,6 +13,7 @@1313 		"@noble/hashes": "^2.4.0",1414 		"hono": "^4.13.9",1515 		"markdown-it": "^15.0.2",16+		"nodemailer": "^10.0.12",1617 		"shiki": "^4.4.3"1718 	},1819 	"devDependencies": {

view file

public/css/kepr.css+4 −0
@@ -1542,6 +1542,10 @@15421542 .issues-submit:hover {15431543 	opacity: 0.85;15441544 }1545+.issues-submit:disabled {1546+	opacity: 0.4;1547+	cursor: not-allowed;1548+}15451549 .issues-actions {15461550 	display: flex;15471551 	align-items: center;

view file

src/admin/console.ts+1 −0
@@ -24,6 +24,7 @@2424 	["/admin/issues", "issues"],2525 	["/admin/releases", "releases"],2626 	["/admin/notifications", "notifications"],27+	["/admin/email", "email"],2728 	["/admin/bucket", "bucket"],2829 	["/admin?section=backups", "backups"],2930 	["/admin/site?section=profile", "profile"],

view file

src/admin/email.ts+215 −0
@@ -0,0 +1,215 @@1+import type { Context, Hono } from "hono";2+import { html } from "../pages/html.ts";3+import { relativeTime } from "../pages/layout.ts";4+import { SMTP_SECURITY, type SmtpSettings, saveConfig } from "../server/config.ts";5+import { NOTIFICATION_KINDS, setting } from "../server/db.ts";6+import { sendMail } from "../server/mail.ts";7+import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";8+import { LABELS } from "./notifications.ts";9+import { saveSettings } from "./store.ts";10+11+type Queued = {12+	id: number;13+	subject: string;14+	status: string;15+	attempts: number;16+	last_error: string;17+	created_at: number;18+};19+20+const EMAIL = /^[^\s@<>]+@[^\s@<>]+$/;21+const LISTED = 100;22+const SHOWN: [key: keyof SmtpSettings, label: string][] = [23+	["host", "host"],24+	["port", "port"],25+	["username", "username"],26+	["from", "from address"],27+	["to", "to address (yours)"],28+];29+30+/** The SMTP settings a form describes, a blank password keeping the stored one; or what is wrong. */31+function candidate(fields: Record<string, string>, current?: SmtpSettings): SmtpSettings | string {32+	const value = (key: string) => (fields[key] ?? "").trim();33+	if (value("host") === "") return "The host is required.";34+	if (/[\s\p{C}]/u.test(value("host")) || /[\s\p{C}]/u.test(value("username"))) {35+		return "The host and username cannot contain spaces or control characters.";36+	}37+	const port = Number(value("port"));38+	if (!Number.isInteger(port) || port < 1 || port > 65535) {39+		return "The port is a number from 1 to 65535.";40+	}41+	const security = SMTP_SECURITY.find((known) => known === fields.security);42+	if (!security) return "Pick a security setting.";43+	for (const [key, label] of [44+		["from", "from"],45+		["to", "to"],46+	] as const) {47+		if (!EMAIL.test(value(key))) return `The ${label} address must be an email address.`;48+	}49+	// A password may have spaces at either end, so it is taken as typed.50+	if (/\p{C}/u.test(fields.password ?? "")) {51+		return "The password cannot contain control characters.";52+	}53+	// With no username there is no login, so no password either.54+	const password = fields.password || current?.password || (value("username") ? undefined : "");55+	if (password === undefined) return "A new SMTP server needs its password.";56+	return {57+		host: value("host"),58+		port,59+		security,60+		username: value("username"),61+		password,62+		from: value("from"),63+		to: value("to"),64+	};65+}66+67+/** §24's Email section: SMTP settings, a test send, which kinds to email, and the queue. */68+export function registerEmail(app: Hono, ctx: AdminContext): void {69+	app.get("/admin/email", (c) => c.html(emailPage(c, ctx)));70+71+	app.post("/admin/email", async (c) => {72+		const fields = await textFields(c);73+		const found = (id: string) =>74+			ctx.kepr75+				.query<{ id: number }, [number]>("SELECT id FROM email_queue WHERE id = ?")76+				.get(Number(id));77+		switch (fields.action) {78+			case "test":79+			case "save": {80+				const next = candidate(fields, ctx.config.smtp);81+				if (typeof next === "string") {82+					return c.html(emailPage(c, ctx, { fields, error: next }), 400);83+				}84+				if (fields.action === "test") {85+					try {86+						await sendMail(next, "test email", "Kepr can send you email.\n");87+					} catch (error) {88+						const error_ = `The test email was not sent: ${error instanceof Error ? error.message : error}`;89+						return c.html(emailPage(c, ctx, { fields, error: error_ }), 400);90+					}91+					const status = `Test email sent to ${next.to}. Nothing was saved.`;92+					return c.html(emailPage(c, ctx, { fields, status }));93+				}94+				try {95+					await saveConfig(ctx.configPath, { ...ctx.config, smtp: next });96+				} catch (error) {97+					const failed = `kepr.toml could not be written: ${error instanceof Error ? error.message : error}`;98+					return c.html(emailPage(c, ctx, { fields, error: failed }), 500);99+				}100+				// In place: the delivery loop reads the same config object.101+				ctx.config.smtp = next;102+				break;103+			}104+			case "preferences": {105+				const on = NOTIFICATION_KINDS.filter((kind) => fields[kind] === "1");106+				if (on.length > 0 && !ctx.config.smtp) {107+					const error = "Save the SMTP settings before emailing notifications.";108+					return c.html(emailPage(c, ctx, { error }), 400);109+				}110+				saveSettings(111+					ctx.kepr,112+					Object.fromEntries(113+						NOTIFICATION_KINDS.map((kind) => [`email_on_${kind}`, on.includes(kind) ? "1" : ""]),114+					),115+				);116+				break;117+			}118+			case "retry":119+				if (!found(fields.id ?? "")) return c.notFound();120+				// A fresh start: every retry is available again.121+				ctx.kepr122+					.query(123+						`UPDATE email_queue SET status = 'waiting', attempts = 0, next_at = ?124+						WHERE id = ?`,125+					)126+					.run(Math.floor(Date.now() / 1000), Number(fields.id));127+				break;128+			case "discard":129+				if (!found(fields.id ?? "")) return c.notFound();130+				ctx.kepr.query("DELETE FROM email_queue WHERE id = ?").run(Number(fields.id));131+				break;132+			default:133+				return c.text("Unknown action.", 400);134+		}135+		return c.redirect("/admin/email?saved=1", 303);136+	});137+}138+139+function emailPage(140+	c: Context,141+	ctx: AdminContext,142+	shown: { fields?: Record<string, string>; error?: string; status?: string } = {},143+): string {144+	const smtp = ctx.config.smtp;145+	const value = (key: keyof SmtpSettings) =>146+		shown.fields?.[key] ?? (smtp ? String(smtp[key]) : key === "port" ? "587" : "");147+	const input = ([key, label]: (typeof SHOWN)[number]) =>148+		html`<label class="issues-label" for="smtp-${key}">${label}</label>149+<input class="issues-input mono" id="smtp-${key}" name="${key}" value="${value(key)}"150+autocomplete="off" spellcheck="false">`;151+	const security = value("security") || "starttls";152+	const queue = ctx.kepr153+		.query<Queued, [number]>(154+			`SELECT id, subject, status, attempts, last_error, created_at FROM email_queue155+			ORDER BY id DESC156+			LIMIT ?`,157+		)158+		.all(LISTED);159+	const action = (email: Queued, name: string, label: string) =>160+		html`<form method="post" action="/admin/email">${formField(c)}161+<input type="hidden" name="action" value="${name}">162+<input type="hidden" name="id" value="${String(email.id)}">163+<button class="issues-cancel mono" type="submit">${label}</button></form>`;164+	const row = (email: Queued) => html`<article class="issue-post">165+<p class="issue-post-head mono"><span class="issue-author">${email.status}${166+		email.attempts > 0167+			? ` · ${email.attempts} ${email.attempts === 1 ? "attempt" : "attempts"}`168+			: ""169+	}</span><span class="history-when">${relativeTime(email.created_at)}</span></p>170+<div class="issue-text">${email.subject}</div>171+${email.last_error && email.status !== "sent" && html`<p class="issues-error mono">${email.last_error}</p>`}172+<div class="issues-actions">${email.status !== "sent" && action(email, "retry", "retry now")}173+${action(email, "discard", "discard")}</div>174+</article>`;175+	const kind = (k: (typeof NOTIFICATION_KINDS)[number]) =>176+		html`<label class="issues-label"><input type="checkbox" name="${k}" value="1"${177+			setting(ctx.kepr, `email_on_${k}`) ? html` checked` : ""178+		}${smtp ? "" : html` disabled`}> ${LABELS[k]}</label>`;179+	const body = html`${notice(shown.error ?? "")}180+${shown.status && html`<p class="admin-notice" role="status">${shown.status}</p>`}181+<p class="admin-intro">Kepr can email you its notifications. The password is never shown.</p>182+<form class="issues-form" method="post" action="/admin/email">183+${formField(c)}184+${SHOWN.slice(0, 2).map(input)}185+<label class="issues-label" for="smtp-security">security</label>186+<select class="issues-input" id="smtp-security" name="security">187+${SMTP_SECURITY.map(188+	(option) =>189+		html`<option value="${option}"${option === security ? html` selected` : ""}>${option}</option>`,190+)}191+</select>192+${SHOWN.slice(2, 3).map(input)}193+<label class="issues-label" for="smtp-password">password</label>194+<input class="issues-input mono" id="smtp-password" name="password" type="password"195+autocomplete="new-password" placeholder="${smtp ? "unchanged" : ""}">196+${SHOWN.slice(3).map(input)}197+<p class="muted">Saved to kepr.toml, without its comments. "tls" is port 465, "starttls" usually198+587, and "none" only for a relay on this machine.</p>199+<div class="issues-actions">200+<button class="issues-submit" type="submit" name="action" value="test">send test</button>201+<button class="issues-submit" type="submit" name="action" value="save">save</button>202+</div>203+</form>204+<section class="repo-section"><h2 class="section-label mono">email me when</h2>205+<form class="issues-form" method="post" action="/admin/email">206+${formField(c)}<input type="hidden" name="action" value="preferences">207+${NOTIFICATION_KINDS.map(kind)}208+${!smtp && html`<p class="muted">Save the SMTP settings first.</p>`}209+<button class="issues-submit" type="submit"${smtp ? "" : html` disabled`}>save</button>210+</form></section>211+<section class="repo-section"><h2 class="section-label mono">delivery</h2>212+${queue.length > 0 ? queue.map(row) : html`<p class="muted mono save-empty">nothing sent yet</p>`}213+</section>`;214+	return adminPage(c, ctx, "email", body);215+}

view file

src/admin/notifications.ts+1 −1
@@ -7,7 +7,7 @@77 88 type Notification = { id: number; text: string; href: string; created_at: number; read: number };99 10-const LABELS: Record<NotificationKind, string> = {10+export const LABELS: Record<NotificationKind, string> = {1111 	issue_filed: "an issue is filed",1212 	issue_followup: "a reporter adds a follow-up",1313 	sync_failed: "a repo's sync starts failing",

view file

src/main.ts+2 −0
@@ -5,6 +5,7 @@55 import { startBackupLoop } from "./server/backup.ts";66 import { CONFIG_FILE, ConfigError, loadConfig } from "./server/config.ts";77 import { openKeprDb } from "./server/db.ts";8+import { startMailLoop } from "./server/mail.ts";89 910 try {1011 	const config = await loadConfig(CONFIG_FILE);@@ -12,6 +13,7 @@1213 	// One bucket for the sync loop and the app, so settings saved in the console reach both.1314 	const bucket = openBucket(config.bucket);1415 	startBackupLoop(kepr, config.dataDir);16+	startMailLoop(kepr, config);1517 	startSyncLoop({1618 		bucket,1719 		kepr,

view file

src/server/app.ts+2 −0
@@ -5,6 +5,7 @@55 import { secureHeaders } from "hono/secure-headers";66 import { registerBucket } from "../admin/bucket.ts";77 import { registerStatus } from "../admin/console.ts";8+import { registerEmail } from "../admin/email.ts";89 import { registerIssues } from "../admin/issues.ts";910 import { registerNotifications } from "../admin/notifications.ts";1011 import { registerReleases } from "../admin/releases.ts";@@ -127,6 +128,7 @@127128 	registerSite(app, admin);128129 	registerBucket(app, admin);129130 	registerNotifications(app, admin);131+	registerEmail(app, admin);130132 	registerRepoText(app, admin);131133 	registerHousekeeping(app, ctx);132134 	registerFrontPages(app, ctx);

view file

src/server/config.ts+49 −0
@@ -13,11 +13,26 @@1313 	// A directory laid out exactly like the bucket (Koh 3's folder remote), for development and tests.1414 	| { kind: "folder"; path: string };1515 16+export const SMTP_SECURITY = ["tls", "starttls", "none"] as const;17+18+/** Where the operator's email goes out (§24); the password lives here and nowhere else (§25). */19+export type SmtpSettings = {20+	host: string;21+	port: number;22+	/** Implicit TLS, STARTTLS, or none for a relay on the same machine. */23+	security: (typeof SMTP_SECURITY)[number];24+	username: string;25+	password: string;26+	from: string;27+	to: string;28+};29+1630 export type Config = {1731 	domain: string;1832 	dataDir: string;1933 	adminPasswordHash: string;2034 	bucket: BucketSource;35+	smtp?: SmtpSettings;2136 };2237 2338 export class ConfigError extends Error {}@@ -50,9 +65,29 @@5065 		dataDir: requireString(top, "data_dir"),5166 		adminPasswordHash,5267 		bucket: parseBucket(bucket),68+		...(top.smtp === undefined ? {} : { smtp: parseSmtp(asTable(top.smtp, "[smtp]")) }),5369 	};5470 }5571 72+function parseSmtp(smtp: Record<string, unknown>): SmtpSettings {73+	const { port, security } = smtp;74+	if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {75+		throw new ConfigError("[smtp] port must be a whole number from 1 to 65535");76+	}77+	const known = SMTP_SECURITY.find((value) => value === security);78+	if (!known) throw new ConfigError(`[smtp] security must be one of ${SMTP_SECURITY.join(", ")}`);79+	const optional = (key: string) => (typeof smtp[key] === "string" ? (smtp[key] as string) : "");80+	return {81+		host: requireString(smtp, "host", "[smtp] "),82+		port,83+		security: known,84+		username: optional("username"),85+		password: optional("password"),86+		from: requireString(smtp, "from", "[smtp] "),87+		to: requireString(smtp, "to", "[smtp] "),88+	};89+}90+5691 /** Where `main` reads the config, and where the console's Bucket section writes it back (§19). */5792 export const CONFIG_FILE = "kepr.toml";5893 @@ -88,6 +123,20 @@88123 				`secret_access_key = ${str(bucket.write.secretAccessKey)}`,89124 			);90125 		}126+	}127+	const { smtp } = config;128+	if (smtp) {129+		lines.push(130+			"",131+			"[smtp]",132+			`host = ${str(smtp.host)}`,133+			`port = ${smtp.port}`,134+			`security = ${str(smtp.security)}`,135+			`username = ${str(smtp.username)}`,136+			`password = ${str(smtp.password)}`,137+			`from = ${str(smtp.from)}`,138+			`to = ${str(smtp.to)}`,139+		);91140 	}92141 	const text = `${lines.join("\n")}\n`;93142 	parseConfig(text);

view file

src/server/db.ts+37 −12
@@ -101,6 +101,18 @@101101 	created_at INTEGER NOT NULL,102102 	read       INTEGER NOT NULL DEFAULT 0103103 );104+-- §24: notifications on their way to the operator's inbox; the address is kepr.toml's.105+CREATE TABLE IF NOT EXISTS email_queue (106+	id         INTEGER PRIMARY KEY,107+	subject    TEXT NOT NULL,108+	href       TEXT NOT NULL,109+	status     TEXT NOT NULL DEFAULT 'waiting' CHECK (status IN ('waiting', 'sent', 'failed')),110+	attempts   INTEGER NOT NULL DEFAULT 0,111+	next_at    INTEGER NOT NULL,112+	last_error TEXT NOT NULL DEFAULT '',113+	created_at INTEGER NOT NULL,114+	sent_at    INTEGER115+);104116 `;105117 106118 /** What raises a notification (§24); each can be switched off. */@@ -177,20 +189,33 @@177189 	db.query("INSERT OR IGNORE INTO repos (name) VALUES (?)").run(name);178190 }179191 180-/** Tells the operator, unless that kind is switched off; only the newest 200 are kept. */192+/**193+ * Tells the operator, unless that kind is switched off, and queues an email when that kind is194+ * emailed. The newest 200 of each are kept.195+ */181196 export function notify(db: Database, kind: NotificationKind, text: string, href: string): void {182-	if (setting(db, `notify_off_${kind}`)) return;197+	const now = Math.floor(Date.now() / 1000);183198 	db.transaction(() => {184-		db.query("INSERT INTO notifications (kind, text, href, created_at) VALUES (?, ?, ?, ?)").run(185-			kind,186-			text,187-			href,188-			Math.floor(Date.now() / 1000),189-		);190-		db.query(191-			`DELETE FROM notifications192-			WHERE id NOT IN (SELECT id FROM notifications ORDER BY id DESC LIMIT ?)`,193-		).run(NOTIFICATIONS_KEPT);199+		if (!setting(db, `notify_off_${kind}`)) {200+			db.query("INSERT INTO notifications (kind, text, href, created_at) VALUES (?, ?, ?, ?)").run(201+				kind,202+				text,203+				href,204+				now,205+			);206+		}207+		if (setting(db, `email_on_${kind}`)) {208+			db.query(209+				"INSERT INTO email_queue (subject, href, next_at, created_at) VALUES (?, ?, ?, ?)",210+			).run(text, href, now, now);211+		}212+		for (const table of ["notifications", "email_queue"]) {213+			// `table` is one of the two names above, never input.214+			db.query(215+				`DELETE FROM ${table}216+				WHERE id NOT IN (SELECT id FROM ${table} ORDER BY id DESC LIMIT ?)`,217+			).run(NOTIFICATIONS_KEPT);218+		}194219 	})();195220 }196221 

view file

src/server/mail.ts+87 −0
@@ -0,0 +1,87 @@1+import type { Database } from "bun:sqlite";2+import { createTransport } from "nodemailer";3+import type { Config, SmtpSettings } from "./config.ts";4+5+const LOOP_MS = 60 * 1000;6+// After the first attempt fails, the waits before each retry; then the email is marked failed.7+const RETRY_MINUTES = [1, 5, 30, 120, 720];8+const TIMEOUT_MS = 15 * 1000;9+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> {12+	const transport = createTransport({13+		host: smtp.host,14+		port: smtp.port,15+		secure: smtp.security === "tls",16+		requireTLS: smtp.security === "starttls",17+		ignoreTLS: smtp.security === "none",18+		...(smtp.username ? { auth: { user: smtp.username, pass: smtp.password } } : {}),19+		connectionTimeout: TIMEOUT_MS,20+		greetingTimeout: TIMEOUT_MS,21+		socketTimeout: TIMEOUT_MS,22+	});23+	try {24+		await transport.sendMail({25+			from: smtp.from,26+			to: smtp.to,27+			// 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,30+		});31+	} finally {32+		transport.close();33+	}34+}35+36+type Queued = { id: number; subject: string; href: string; attempts: number };37+38+let running: Promise<void> | null = null;39+40+/** Sends every queued email that is due (§24); never overlaps itself. */41+export function deliverDue(kepr: Database, config: Config, now = Date.now()): Promise<void> {42+	running ??= deliver(kepr, config, Math.floor(now / 1000)).finally(() => {43+		running = null;44+	});45+	return running;46+}47+48+async function deliver(kepr: Database, config: Config, now: number): Promise<void> {49+	const due = kepr50+		.query<Queued, [number]>(51+			`SELECT id, subject, href, attempts FROM email_queue52+			WHERE status = 'waiting' AND next_at <= ?53+			ORDER BY id`,54+		)55+		.all(now);56+	for (const email of due) {57+		try {58+			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`);61+			kepr62+				.query("UPDATE email_queue SET status = 'sent', attempts = ?, sent_at = ? WHERE id = ?")63+				.run(email.attempts + 1, now, email.id);64+		} catch (error) {65+			const attempts = email.attempts + 1;66+			const wait = RETRY_MINUTES[attempts - 1];67+			kepr68+				.query(69+					`UPDATE email_queue SET status = ?, attempts = ?, next_at = ?, last_error = ?70+					WHERE id = ?`,71+				)72+				.run(73+					wait === undefined ? "failed" : "waiting",74+					attempts,75+					now + (wait ?? 0) * 60,76+					error instanceof Error ? error.message : String(error),77+					email.id,78+				);79+		}80+	}81+}82+83+export function startMailLoop(kepr: Database, config: Config): void {84+	setInterval(() => {85+		deliverDue(kepr, config).catch((error) => console.error(`kepr: email failed: ${error}`));86+	}, LOOP_MS);87+}

view file

tests/email.test.ts+254 −0
@@ -0,0 +1,254 @@1+// Covers §24's Email section: SMTP settings in kepr.toml, the test send, emailed notifications2+// and the delivery queue's retries, retry now and discard. SMTP is a fake server in this process.3+4+import { beforeEach, describe, expect, test } from "bun:test";5+import { mkdtemp, stat } from "node:fs/promises";6+import { tmpdir } from "node:os";7+import { join } from "node:path";8+import { openBucket } from "../src/bucket/reader.ts";9+import { createApp } from "../src/server/app.ts";10+import { type Config, loadConfig, parseConfig, saveConfig } from "../src/server/config.ts";11+import { notify, openKeprDb } from "../src/server/db.ts";12+import { deliverDue } from "../src/server/mail.ts";13+import { Browser, PASSWORD } from "./browser.ts";14+15+const HASH = await Bun.password.hash(PASSWORD, "argon2id");16+type Received = { from: string; to: string; data: string };17+const received: Received[] = [];18+let refuse = false;19+20+// Just enough SMTP for nodemailer: EHLO, AUTH PLAIN, MAIL, RCPT, DATA, QUIT. `refuse` turns MAIL away.21+const smtp = Bun.listen<{ buffer: string; data: boolean; mail: Received }>({22+	hostname: "127.0.0.1",23+	port: 0,24+	socket: {25+		open(socket) {26+			socket.data = { buffer: "", data: false, mail: { from: "", to: "", data: "" } };27+			socket.write("220 fake ESMTP\r\n");28+		},29+		data(socket, bytes) {30+			const state = socket.data;31+			state.buffer += new TextDecoder().decode(bytes);32+			while (true) {33+				if (state.data) {34+					const end = state.buffer.indexOf("\r\n.\r\n");35+					if (end < 0) return;36+					state.mail.data = state.buffer.slice(0, end);37+					state.buffer = state.buffer.slice(end + 5);38+					state.data = false;39+					received.push(state.mail);40+					state.mail = { from: "", to: "", data: "" };41+					socket.write("250 queued\r\n");42+					continue;43+				}44+				const end = state.buffer.indexOf("\r\n");45+				if (end < 0) return;46+				const line = state.buffer.slice(0, end);47+				state.buffer = state.buffer.slice(end + 2);48+				const verb = line.slice(0, 4).toUpperCase();49+				if (verb === "EHLO") socket.write("250-fake\r\n250 AUTH PLAIN LOGIN\r\n");50+				else if (verb === "AUTH") socket.write("235 ok\r\n");51+				else if (verb === "MAIL") {52+					if (refuse) socket.write("550 no thanks\r\n");53+					else {54+						state.mail.from = line;55+						socket.write("250 ok\r\n");56+					}57+				} else if (verb === "RCPT") {58+					state.mail.to = line;59+					socket.write("250 ok\r\n");60+				} else if (verb === "DATA") {61+					state.data = true;62+					socket.write("354 go\r\n");63+				} else if (verb === "QUIT") {64+					socket.write("221 bye\r\n");65+					socket.end();66+				} else socket.write("250 ok\r\n");67+			}68+		},69+	},70+});71+72+const SMTP = {73+	host: "127.0.0.1",74+	port: smtp.port,75+	security: "none" as const,76+	username: "kepr",77+	password: "MAIL-SECRET",78+	from: "[email protected]",79+	to: "[email protected]",80+};81+82+async function build(withSmtp: boolean) {83+	const dataDir = await mkdtemp(join(tmpdir(), "kepr-email-"));84+	const configPath = join(dataDir, "kepr.toml");85+	const config: Config = {86+		domain: "kepr.example",87+		dataDir,88+		adminPasswordHash: HASH,89+		bucket: { kind: "folder", path: dataDir },90+		...(withSmtp ? { smtp: { ...SMTP } } : {}),91+	};92+	await saveConfig(configPath, config);93+	const original = await Bun.file(configPath).text();94+	const kepr = openKeprDb(dataDir);95+	const app = createApp(config, kepr, openBucket(config.bucket), configPath);96+	const browser = new Browser(app);97+	await browser.signIn();98+	await browser.get("/admin/email");99+	return { config, configPath, original, kepr, browser };100+}101+102+const form = (fields: Record<string, string>) => ({103+	host: "127.0.0.1",104+	port: String(smtp.port),105+	security: "none",106+	username: "kepr",107+	password: "",108+	from: "[email protected]",109+	to: "[email protected]",110+	...fields,111+});112+113+beforeEach(() => {114+	received.length = 0;115+	refuse = false;116+});117+118+describe("SMTP settings (§19, §24)", () => {119+	test("kepr.toml's [smtp] parses, saves back and refuses nonsense", () => {120+		const base = `domain = "kepr.example"\ndata_dir = "d"\nadmin_password_hash = "${HASH}"\n[bucket]\nfolder = "b"\n`;121+		const smtpToml = `[smtp]\nhost = "mail.example"\nport = 587\nsecurity = "starttls"\nusername = "u"\npassword = "p"\nfrom = "[email protected]"\nto = "[email protected]"\n`;122+		expect(parseConfig(base + smtpToml).smtp).toEqual({123+			host: "mail.example",124+			port: 587,125+			security: "starttls",126+			username: "u",127+			password: "p",128+			from: "[email protected]",129+			to: "[email protected]",130+		});131+		expect(() => parseConfig(base + smtpToml.replace("587", "70000"))).toThrow("[smtp] port");132+		expect(() => parseConfig(base + smtpToml.replace('"starttls"', '"ssl"'))).toThrow(133+			"[smtp] security",134+		);135+	});136+137+	test("the page shows every setting but the password", async () => {138+		const { browser } = await build(true);139+		const text = await (await browser.get("/admin/email")).text();140+		expect(text).toContain('value="[email protected]"');141+		expect(text).toContain('<option value="none" selected>');142+		expect(text).not.toContain("MAIL-SECRET");143+	});144+145+	test("send test mails the form's settings at once and saves nothing", async () => {146+		const { browser, configPath, original } = await build(false);147+		const sent = await browser.post("/admin/email", form({ action: "test", password: "x" }));148+		expect(await sent.text()).toContain("Test email sent to [email protected].");149+		expect(sent.status).toBe(200);150+		expect(received.length).toBe(1);151+		expect(received[0]?.to).toContain("[email protected]");152+		expect(received[0]?.data).toContain("Subject: kepr: test email");153+		refuse = true;154+		const failed = await browser.post("/admin/email", form({ action: "test", password: "x" }));155+		expect(failed.status).toBe(400);156+		expect(await failed.text()).toContain("The test email was not sent:");157+		expect(await Bun.file(configPath).text()).toBe(original);158+	});159+160+	test("save rewrites kepr.toml, keeping a blank password and the bucket", async () => {161+		const { browser, configPath, config } = await build(true);162+		const saved = await browser.post("/admin/email", form({ action: "save", port: "2525" }));163+		expect(saved.status).toBe(303);164+		const reloaded = await loadConfig(configPath);165+		expect(reloaded.smtp).toEqual({ ...SMTP, port: 2525 });166+		expect(reloaded.bucket).toEqual(config.bucket);167+		expect(config.smtp?.port).toBe(2525);168+		expect((await stat(configPath)).mode & 0o777).toBe(0o600);169+	});170+171+	test("refuses a malformed form", async () => {172+		const { browser, configPath, original } = await build(false);173+		const cases: [Record<string, string>, string][] = [174+			[{ host: "" }, "The host is required."],175+			[{ port: "0" }, "The port is a number from 1 to 65535."],176+			[{ security: "ssl" }, "Pick a security setting."],177+			[{ to: "not an address" }, "The to address must be an email address."],178+			[{ password: "a\nb" }, "The password cannot contain control characters."],179+			[{}, "A new SMTP server needs its password"],180+		];181+		for (const [fields, error] of cases) {182+			const response = await browser.post("/admin/email", form({ action: "save", ...fields }));183+			expect(response.status).toBe(400);184+			expect(await response.text()).toContain(error);185+		}186+		expect(await Bun.file(configPath).text()).toBe(original);187+	});188+});189+190+describe("emailed notifications and the delivery queue (§24)", () => {191+	const rows = (kepr: ReturnType<typeof openKeprDb>) =>192+		kepr193+			.query<194+				{ id: number; status: string; attempts: number; next_at: number; last_error: string },195+				[]196+			>("SELECT id, status, attempts, next_at, last_error FROM email_queue ORDER BY id")197+			.all();198+199+	test("emailing a kind can be switched on only with SMTP settings", async () => {200+		const { browser, kepr } = await build(false);201+		const on = await browser.post("/admin/email", { action: "preferences", issue_filed: "1" });202+		expect(on.status).toBe(400);203+		notify(kepr, "issue_filed", "New issue app #1: t", "/admin/issues/app/1");204+		expect(rows(kepr).length).toBe(0);205+	});206+207+	test("a switched-on kind is queued and delivered with a link back", async () => {208+		const { browser, kepr, config } = await build(true);209+		expect(210+			(await browser.post("/admin/email", { action: "preferences", issue_filed: "1" })).status,211+		).toBe(303);212+		notify(kepr, "issue_filed", "New issue app #1: t", "/admin/issues/app/1");213+		notify(kepr, "sync_failed", "Sync failed for app: x", "/admin/repos?repo=app");214+		expect(rows(kepr).map((row) => row.status)).toEqual(["waiting"]);215+		await deliverDue(kepr, config);216+		expect(rows(kepr)[0]?.status).toBe("sent");217+		expect(received.length).toBe(1);218+		expect(received[0]?.data).toContain("Subject: kepr: New issue app #1: t");219+		expect(received[0]?.data).toContain("https://kepr.example/admin/issues/app/1");220+	});221+222+	test("a failure retries after 1, 5, 30, 120 and 720 minutes, then fails; retry and discard", async () => {223+		const { browser, kepr, config } = await build(true);224+		kepr.run("INSERT INTO settings (key, value) VALUES ('email_on_backup_failed', '1')");225+		notify(kepr, "backup_failed", "Backup failed: disk full", "/admin?section=backups");226+		refuse = true;227+		let now = Date.now();228+		for (const minutes of [1, 5, 30, 120, 720]) {229+			await deliverDue(kepr, config, now);230+			const row = rows(kepr)[0];231+			expect(row?.status).toBe("waiting");232+			expect(row?.next_at).toBe(Math.floor(now / 1000) + minutes * 60);233+			expect(row?.last_error).toContain("550");234+			// Not due yet: nothing happens.235+			await deliverDue(kepr, config, now + 1000);236+			now += minutes * 60 * 1000;237+		}238+		await deliverDue(kepr, config, now);239+		expect(rows(kepr)[0]?.status).toBe("failed");240+		expect(rows(kepr)[0]?.attempts).toBe(6);241+		const page = await (await browser.get("/admin/email")).text();242+		expect(page).toContain("Backup failed: disk full");243+		expect(page).toContain("failed · 6 attempts");244+		const id = String(rows(kepr)[0]?.id);245+		expect((await browser.post("/admin/email", { action: "retry", id })).status).toBe(303);246+		expect(rows(kepr)[0]?.status).toBe("waiting");247+		refuse = false;248+		await deliverDue(kepr, config);249+		expect(rows(kepr)[0]?.status).toBe("sent");250+		expect((await browser.post("/admin/email", { action: "discard", id })).status).toBe(303);251+		expect(rows(kepr).length).toBe(0);252+		expect((await browser.post("/admin/email", { action: "retry", id })).status).toBe(404);253+	});254+});

view file