email.ts

8.7 KB · raw

1import type { Context, Hono } from "hono";2import { html } from "../pages/html.ts";3import { relativeTime } from "../pages/layout.ts";4import { SMTP_SECURITY, type SmtpSettings, saveConfig } from "../server/config.ts";5import { NOTIFICATION_KINDS, setting } from "../server/db.ts";6import { sendMail } from "../server/mail.ts";7import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";8import { LABELS } from "./notifications.ts";9import { saveSettings } from "./store.ts";1011type Queued = {12	id: number;13	subject: string;14	status: string;15	attempts: number;16	last_error: string;17	created_at: number;18};1920const EMAIL = /^[^\s@<>]+@[^\s@<>]+$/;21const LISTED = 100;22const 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];2930/** The SMTP settings a form describes, a blank password keeping the stored one; or what is wrong. */31function 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}6667/** §24's Email section: SMTP settings, a test send, which kinds to email, and the queue. */68export function registerEmail(app: Hono, ctx: AdminContext): void {69	app.get("/admin/email", (c) => c.html(emailPage(c, ctx)));7071	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}138139function 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)}"150autocomplete="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"195autocomplete="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" usually198587, 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}