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];293031function 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 50 if (/\p{C}/u.test(fields.password ?? "")) {51 return "The password cannot contain control characters.";52 }53 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}666768export 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 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 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}