email.test.ts

9.9 KB · raw

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.34import { 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;1920// Just enough SMTP for nodemailer: EHLO, AUTH PLAIN, MAIL, RCPT, DATA, QUIT. `refuse` turns MAIL away.21const 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			// 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});