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
↓ kepr-301147ee.tar.gzatlas at this save
files changed12 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 −0bun.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 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": {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;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"],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+}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",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,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);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);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 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+}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+});