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