// Covers §24's Email section: SMTP settings in kepr.toml, the test send, emailed notifications // and the delivery queue's retries, retry now and discard. SMTP is a fake server in this process. import { beforeEach, describe, expect, test } from "bun:test"; import { mkdtemp, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { openBucket } from "../src/bucket/reader.ts"; import { createApp } from "../src/server/app.ts"; import { type Config, loadConfig, parseConfig, saveConfig } from "../src/server/config.ts"; import { notify, openKeprDb } from "../src/server/db.ts"; import { deliverDue } from "../src/server/mail.ts"; import { Browser, PASSWORD } from "./browser.ts"; const HASH = await Bun.password.hash(PASSWORD, "argon2id"); type Received = { from: string; to: string; data: string }; const received: Received[] = []; let refuse = false; // Just enough SMTP for nodemailer: EHLO, AUTH PLAIN, MAIL, RCPT, DATA, QUIT. `refuse` turns MAIL away. const smtp = Bun.listen<{ buffer: string; data: boolean; mail: Received }>({ hostname: "127.0.0.1", port: 0, socket: { open(socket) { socket.data = { buffer: "", data: false, mail: { from: "", to: "", data: "" } }; socket.write("220 fake ESMTP\r\n"); }, data(socket, bytes) { const state = socket.data; state.buffer += new TextDecoder().decode(bytes); while (true) { if (state.data) { const end = state.buffer.indexOf("\r\n.\r\n"); if (end < 0) return; state.mail.data = state.buffer.slice(0, end); state.buffer = state.buffer.slice(end + 5); state.data = false; received.push(state.mail); state.mail = { from: "", to: "", data: "" }; socket.write("250 queued\r\n"); continue; } const end = state.buffer.indexOf("\r\n"); if (end < 0) return; const line = state.buffer.slice(0, end); state.buffer = state.buffer.slice(end + 2); const verb = line.slice(0, 4).toUpperCase(); if (verb === "EHLO") socket.write("250-fake\r\n250 AUTH PLAIN LOGIN\r\n"); else if (verb === "AUTH") socket.write("235 ok\r\n"); else if (verb === "MAIL") { if (refuse) socket.write("550 no thanks\r\n"); else { state.mail.from = line; socket.write("250 ok\r\n"); } } else if (verb === "RCPT") { state.mail.to = line; socket.write("250 ok\r\n"); } else if (verb === "DATA") { state.data = true; socket.write("354 go\r\n"); } else if (verb === "QUIT") { socket.write("221 bye\r\n"); socket.end(); } else socket.write("250 ok\r\n"); } }, }, }); const SMTP = { host: "127.0.0.1", port: smtp.port, security: "none" as const, username: "kepr", password: "MAIL-SECRET", from: "kepr@kepr.example", to: "june@example.com", }; async function build(withSmtp: boolean) { const dataDir = await mkdtemp(join(tmpdir(), "kepr-email-")); const configPath = join(dataDir, "kepr.toml"); const config: Config = { domain: "kepr.example", dataDir, adminPasswordHash: HASH, bucket: { kind: "folder", path: dataDir }, ...(withSmtp ? { smtp: { ...SMTP } } : {}), }; await saveConfig(configPath, config); const original = await Bun.file(configPath).text(); const kepr = openKeprDb(dataDir); const app = createApp(config, kepr, openBucket(config.bucket), configPath); const browser = new Browser(app); await browser.signIn(); await browser.get("/admin/email"); return { config, configPath, original, kepr, browser }; } const form = (fields: Record) => ({ host: "127.0.0.1", port: String(smtp.port), security: "none", username: "kepr", password: "", from: "kepr@kepr.example", to: "june@example.com", ...fields, }); beforeEach(() => { received.length = 0; refuse = false; }); describe("SMTP settings (§19, §24)", () => { test("kepr.toml's [smtp] parses, saves back and refuses nonsense", () => { const base = `domain = "kepr.example"\ndata_dir = "d"\nadmin_password_hash = "${HASH}"\n[bucket]\nfolder = "b"\n`; const smtpToml = `[smtp]\nhost = "mail.example"\nport = 587\nsecurity = "starttls"\nusername = "u"\npassword = "p"\nfrom = "a@b.c"\nto = "d@e.f"\n`; expect(parseConfig(base + smtpToml).smtp).toEqual({ host: "mail.example", port: 587, security: "starttls", username: "u", password: "p", from: "a@b.c", to: "d@e.f", }); expect(() => parseConfig(base + smtpToml.replace("587", "70000"))).toThrow("[smtp] port"); expect(() => parseConfig(base + smtpToml.replace('"starttls"', '"ssl"'))).toThrow( "[smtp] security", ); }); test("the page shows every setting but the password", async () => { const { browser } = await build(true); const text = await (await browser.get("/admin/email")).text(); expect(text).toContain('value="kepr@kepr.example"'); expect(text).toContain('