import type { Context, Hono } from "hono"; import { html } from "../pages/html.ts"; import { relativeTime } from "../pages/layout.ts"; import { SMTP_SECURITY, type SmtpSettings, saveConfig } from "../server/config.ts"; import { NOTIFICATION_KINDS, setting } from "../server/db.ts"; import { sendMail } from "../server/mail.ts"; import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts"; import { LABELS } from "./notifications.ts"; import { saveSettings } from "./store.ts"; type Queued = { id: number; subject: string; status: string; attempts: number; last_error: string; created_at: number; }; const EMAIL = /^[^\s@<>]+@[^\s@<>]+$/; const LISTED = 100; const SHOWN: [key: keyof SmtpSettings, label: string][] = [ ["host", "host"], ["port", "port"], ["username", "username"], ["from", "from address"], ["to", "to address (yours)"], ]; /** The SMTP settings a form describes, a blank password keeping the stored one; or what is wrong. */ function candidate(fields: Record, current?: SmtpSettings): SmtpSettings | string { const value = (key: string) => (fields[key] ?? "").trim(); if (value("host") === "") return "The host is required."; if (/[\s\p{C}]/u.test(value("host")) || /[\s\p{C}]/u.test(value("username"))) { return "The host and username cannot contain spaces or control characters."; } const port = Number(value("port")); if (!Number.isInteger(port) || port < 1 || port > 65535) { return "The port is a number from 1 to 65535."; } const security = SMTP_SECURITY.find((known) => known === fields.security); if (!security) return "Pick a security setting."; for (const [key, label] of [ ["from", "from"], ["to", "to"], ] as const) { if (!EMAIL.test(value(key))) return `The ${label} address must be an email address.`; } // A password may have spaces at either end, so it is taken as typed. if (/\p{C}/u.test(fields.password ?? "")) { return "The password cannot contain control characters."; } // With no username there is no login, so no password either. const password = fields.password || current?.password || (value("username") ? undefined : ""); if (password === undefined) return "A new SMTP server needs its password."; return { host: value("host"), port, security, username: value("username"), password, from: value("from"), to: value("to"), }; } /** §24's Email section: SMTP settings, a test send, which kinds to email, and the queue. */ export function registerEmail(app: Hono, ctx: AdminContext): void { app.get("/admin/email", (c) => c.html(emailPage(c, ctx))); app.post("/admin/email", async (c) => { const fields = await textFields(c); const found = (id: string) => ctx.kepr .query<{ id: number }, [number]>("SELECT id FROM email_queue WHERE id = ?") .get(Number(id)); switch (fields.action) { case "test": case "save": { const next = candidate(fields, ctx.config.smtp); if (typeof next === "string") { return c.html(emailPage(c, ctx, { fields, error: next }), 400); } if (fields.action === "test") { try { await sendMail(next, "test email", "Kepr can send you email.\n"); } catch (error) { const error_ = `The test email was not sent: ${error instanceof Error ? error.message : error}`; return c.html(emailPage(c, ctx, { fields, error: error_ }), 400); } const status = `Test email sent to ${next.to}. Nothing was saved.`; return c.html(emailPage(c, ctx, { fields, status })); } try { await saveConfig(ctx.configPath, { ...ctx.config, smtp: next }); } catch (error) { const failed = `kepr.toml could not be written: ${error instanceof Error ? error.message : error}`; return c.html(emailPage(c, ctx, { fields, error: failed }), 500); } // In place: the delivery loop reads the same config object. ctx.config.smtp = next; break; } case "preferences": { const on = NOTIFICATION_KINDS.filter((kind) => fields[kind] === "1"); if (on.length > 0 && !ctx.config.smtp) { const error = "Save the SMTP settings before emailing notifications."; return c.html(emailPage(c, ctx, { error }), 400); } saveSettings( ctx.kepr, Object.fromEntries( NOTIFICATION_KINDS.map((kind) => [`email_on_${kind}`, on.includes(kind) ? "1" : ""]), ), ); break; } case "retry": if (!found(fields.id ?? "")) return c.notFound(); // A fresh start: every retry is available again. ctx.kepr .query( `UPDATE email_queue SET status = 'waiting', attempts = 0, next_at = ? WHERE id = ?`, ) .run(Math.floor(Date.now() / 1000), Number(fields.id)); break; case "discard": if (!found(fields.id ?? "")) return c.notFound(); ctx.kepr.query("DELETE FROM email_queue WHERE id = ?").run(Number(fields.id)); break; default: return c.text("Unknown action.", 400); } return c.redirect("/admin/email?saved=1", 303); }); } function emailPage( c: Context, ctx: AdminContext, shown: { fields?: Record; error?: string; status?: string } = {}, ): string { const smtp = ctx.config.smtp; const value = (key: keyof SmtpSettings) => shown.fields?.[key] ?? (smtp ? String(smtp[key]) : key === "port" ? "587" : ""); const input = ([key, label]: (typeof SHOWN)[number]) => html` `; const security = value("security") || "starttls"; const queue = ctx.kepr .query( `SELECT id, subject, status, attempts, last_error, created_at FROM email_queue ORDER BY id DESC LIMIT ?`, ) .all(LISTED); const action = (email: Queued, name: string, label: string) => html`
${formField(c)}
`; const row = (email: Queued) => html`

${email.status}${ email.attempts > 0 ? ` · ${email.attempts} ${email.attempts === 1 ? "attempt" : "attempts"}` : "" }${relativeTime(email.created_at)}

${email.subject}
${email.last_error && email.status !== "sent" && html`

${email.last_error}

`}
${email.status !== "sent" && action(email, "retry", "retry now")} ${action(email, "discard", "discard")}
`; const kind = (k: (typeof NOTIFICATION_KINDS)[number]) => html``; const body = html`${notice(shown.error ?? "")} ${shown.status && html`

${shown.status}

`}

Kepr can email you its notifications. The password is never shown.

${formField(c)} ${SHOWN.slice(0, 2).map(input)} ${SHOWN.slice(2, 3).map(input)} ${SHOWN.slice(3).map(input)}

Saved to kepr.toml, without its comments. "tls" is port 465, "starttls" usually 587, and "none" only for a relay on this machine.

${formField(c)} ${NOTIFICATION_KINDS.map(kind)} ${!smtp && html`

Save the SMTP settings first.

`}
${queue.length > 0 ? queue.map(row) : html`

nothing sent yet

`}
`; return adminPage(c, ctx, "email", body); }