email.ts

8.9 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	/** '' for the operator's own address; else an issue follower's (§23). */15	recipient: string;16	status: string;17	attempts: number;18	last_error: string;19	created_at: number;20};2122const EMAIL = /^[^\s@<>]+@[^\s@<>]+$/;23const LISTED = 100;24const SHOWN: [key: keyof SmtpSettings, label: string][] = [25	["host", "host"],26	["port", "port"],27	["username", "username"],28	["from", "from address"],29	["to", "to address (yours)"],30];3132/** The SMTP settings a form describes, a blank password keeping the stored one; or what is wrong. */33function candidate(fields: Record<string, string>, current?: SmtpSettings): SmtpSettings | string {34	const value = (key: string) => (fields[key] ?? "").trim();35	if (value("host") === "") return "The host is required.";36	if (/[\s\p{C}]/u.test(value("host")) || /[\s\p{C}]/u.test(value("username"))) {37		return "The host and username cannot contain spaces or control characters.";38	}39	const port = Number(value("port"));40	if (!Number.isInteger(port) || port < 1 || port > 65535) {41		return "The port is a number from 1 to 65535.";42	}43	const security = SMTP_SECURITY.find((known) => known === fields.security);44	if (!security) return "Pick a security setting.";45	for (const [key, label] of [46		["from", "from"],47		["to", "to"],48	] as const) {49		if (!EMAIL.test(value(key))) return `The ${label} address must be an email address.`;50	}51	// A password may have spaces at either end, so it is taken as typed.52	if (/\p{C}/u.test(fields.password ?? "")) {53		return "The password cannot contain control characters.";54	}55	// With no username there is no login, so no password either.56	const password = fields.password || current?.password || (value("username") ? undefined : "");57	if (password === undefined) return "A new SMTP server needs its password.";58	return {59		host: value("host"),60		port,61		security,62		username: value("username"),63		password,64		from: value("from"),65		to: value("to"),66	};67}6869/** §24's Email section: SMTP settings, a test send, which kinds to email, and the queue. */70export function registerEmail(app: Hono, ctx: AdminContext): void {71	app.get("/admin/email", (c) => c.html(emailPage(c, ctx)));7273	app.post("/admin/email", async (c) => {74		const fields = await textFields(c);75		const found = (id: string) =>76			ctx.kepr77				.query<{ id: number }, [number]>("SELECT id FROM email_queue WHERE id = ?")78				.get(Number(id));79		switch (fields.action) {80			case "test":81			case "save": {82				const next = candidate(fields, ctx.config.smtp);83				if (typeof next === "string") {84					return c.html(emailPage(c, ctx, { fields, error: next }), 400);85				}86				if (fields.action === "test") {87					try {88						await sendMail(next, {89							subject: "kepr: test email",90							text: "Kepr can send you email.\n",91						});92					} catch (error) {93						const error_ = `The test email was not sent: ${error instanceof Error ? error.message : error}`;94						return c.html(emailPage(c, ctx, { fields, error: error_ }), 400);95					}96					const status = `Test email sent to ${next.to}. Nothing was saved.`;97					return c.html(emailPage(c, ctx, { fields, status }));98				}99				try {100					await saveConfig(ctx.configPath, { ...ctx.config, smtp: next });101				} catch (error) {102					const failed = `kepr.toml could not be written: ${error instanceof Error ? error.message : error}`;103					return c.html(emailPage(c, ctx, { fields, error: failed }), 500);104				}105				// In place: the delivery loop reads the same config object.106				ctx.config.smtp = next;107				break;108			}109			case "preferences": {110				const on = NOTIFICATION_KINDS.filter((kind) => fields[kind] === "1");111				if (on.length > 0 && !ctx.config.smtp) {112					const error = "Save the SMTP settings before emailing notifications.";113					return c.html(emailPage(c, ctx, { error }), 400);114				}115				saveSettings(116					ctx.kepr,117					Object.fromEntries(118						NOTIFICATION_KINDS.map((kind) => [`email_on_${kind}`, on.includes(kind) ? "1" : ""]),119					),120				);121				break;122			}123			case "retry":124				if (!found(fields.id ?? "")) return c.notFound();125				// A fresh start: every retry is available again.126				ctx.kepr127					.query(128						`UPDATE email_queue SET status = 'waiting', attempts = 0, next_at = ?129						WHERE id = ?`,130					)131					.run(Math.floor(Date.now() / 1000), Number(fields.id));132				break;133			case "discard":134				if (!found(fields.id ?? "")) return c.notFound();135				ctx.kepr.query("DELETE FROM email_queue WHERE id = ?").run(Number(fields.id));136				break;137			default:138				return c.text("Unknown action.", 400);139		}140		return c.redirect("/admin/email?saved=1", 303);141	});142}143144function emailPage(145	c: Context,146	ctx: AdminContext,147	shown: { fields?: Record<string, string>; error?: string; status?: string } = {},148): string {149	const smtp = ctx.config.smtp;150	const value = (key: keyof SmtpSettings) =>151		shown.fields?.[key] ?? (smtp ? String(smtp[key]) : key === "port" ? "587" : "");152	const input = ([key, label]: (typeof SHOWN)[number]) =>153		html`<label class="issues-label" for="smtp-${key}">${label}</label>154<input class="issues-input mono" id="smtp-${key}" name="${key}" value="${value(key)}"155autocomplete="off" spellcheck="false">`;156	const security = value("security") || "starttls";157	const queue = ctx.kepr158		.query<Queued, [number]>(159			`SELECT id, subject, recipient, status, attempts, last_error, created_at FROM email_queue160			ORDER BY id DESC161			LIMIT ?`,162		)163		.all(LISTED);164	const action = (email: Queued, name: string, label: string) =>165		html`<form method="post" action="/admin/email">${formField(c)}166<input type="hidden" name="action" value="${name}">167<input type="hidden" name="id" value="${String(email.id)}">168<button class="issues-cancel mono" type="submit">${label}</button></form>`;169	const row = (email: Queued) => html`<article class="issue-post">170<p class="issue-post-head mono"><span class="issue-author">${email.status}${171		email.attempts > 0172			? ` · ${email.attempts} ${email.attempts === 1 ? "attempt" : "attempts"}`173			: ""174	}${email.recipient && ` · to ${email.recipient}`}</span>175<span class="history-when">${relativeTime(email.created_at)}</span></p>176<div class="issue-text">${email.subject}</div>177${email.last_error && email.status !== "sent" && html`<p class="issues-error mono">${email.last_error}</p>`}178<div class="issues-actions">${email.status !== "sent" && action(email, "retry", "retry now")}179${action(email, "discard", "discard")}</div>180</article>`;181	const kind = (k: (typeof NOTIFICATION_KINDS)[number]) =>182		html`<label class="issues-label"><input type="checkbox" name="${k}" value="1"${183			setting(ctx.kepr, `email_on_${k}`) ? html` checked` : ""184		}${smtp ? "" : html` disabled`}> ${LABELS[k]}</label>`;185	const body = html`${notice(shown.error ?? "")}186${shown.status && html`<p class="admin-notice" role="status">${shown.status}</p>`}187<p class="admin-intro">Kepr can email you its notifications. The password is never shown.</p>188<form class="issues-form" method="post" action="/admin/email">189${formField(c)}190${SHOWN.slice(0, 2).map(input)}191<label class="issues-label" for="smtp-security">security</label>192<select class="issues-input" id="smtp-security" name="security">193${SMTP_SECURITY.map(194	(option) =>195		html`<option value="${option}"${option === security ? html` selected` : ""}>${option}</option>`,196)}197</select>198${SHOWN.slice(2, 3).map(input)}199<label class="issues-label" for="smtp-password">password</label>200<input class="issues-input mono" id="smtp-password" name="password" type="password"201autocomplete="new-password" placeholder="${smtp ? "unchanged" : ""}">202${SHOWN.slice(3).map(input)}203<p class="muted">Saved to kepr.toml, without its comments. "tls" is port 465, "starttls" usually204587, and "none" only for a relay on this machine.</p>205<div class="issues-actions">206<button class="issues-submit" type="submit" name="action" value="test">send test</button>207<button class="issues-submit" type="submit" name="action" value="save">save</button>208</div>209</form>210<section class="repo-section"><h2 class="section-label mono">email me when</h2>211<form class="issues-form" method="post" action="/admin/email">212${formField(c)}<input type="hidden" name="action" value="preferences">213${NOTIFICATION_KINDS.map(kind)}214${!smtp && html`<p class="muted">Save the SMTP settings first.</p>`}215<button class="issues-submit" type="submit"${smtp ? "" : html` disabled`}>save</button>216</form></section>217<section class="repo-section"><h2 class="section-label mono">delivery</h2>218${queue.length > 0 ? queue.map(row) : html`<p class="muted mono save-empty">nothing sent yet</p>`}219</section>`;220	return adminPage(c, ctx, "email", body);221}