1234import { beforeEach, describe, expect, test } from "bun:test";5import { mkdtemp, stat } from "node:fs/promises";6import { tmpdir } from "node:os";7import { join } from "node:path";8import { openBucket } from "../src/bucket/reader.ts";9import { createApp } from "../src/server/app.ts";10import { type Config, loadConfig, parseConfig, saveConfig } from "../src/server/config.ts";11import { notify, openKeprDb } from "../src/server/db.ts";12import { deliverDue } from "../src/server/mail.ts";13import { Browser, PASSWORD } from "./browser.ts";1415const HASH = await Bun.password.hash(PASSWORD, "argon2id");16type Received = { from: string; to: string; data: string };17const received: Received[] = [];18let refuse = false;192021const 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});7172const 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};8182async 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}101102const 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});112113beforeEach(() => {114 received.length = 0;115 refuse = false;116});117118describe("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 });136137 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 });144145 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 });159160 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 });170171 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});189190describe("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();198199 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 });206207 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 });221222 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 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});