import type { Database } from "bun:sqlite"; import { createTransport } from "nodemailer"; import type { Config, SmtpSettings } from "./config.ts"; const LOOP_MS = 60 * 1000; // After the first attempt fails, the waits before each retry; then the email is marked failed. const RETRY_MINUTES = [1, 5, 30, 120, 720]; const TIMEOUT_MS = 15 * 1000; /** Sends one plain-text email to the operator, now; throws with the server's answer on failure. */ export async function sendMail(smtp: SmtpSettings, subject: string, text: string): Promise { const transport = createTransport({ host: smtp.host, port: smtp.port, secure: smtp.security === "tls", requireTLS: smtp.security === "starttls", ignoreTLS: smtp.security === "none", ...(smtp.username ? { auth: { user: smtp.username, pass: smtp.password } } : {}), connectionTimeout: TIMEOUT_MS, greetingTimeout: TIMEOUT_MS, socketTimeout: TIMEOUT_MS, }); try { await transport.sendMail({ from: smtp.from, to: smtp.to, // Issue titles come from strangers: one line, and short enough for any inbox. subject: `kepr: ${subject}`.replace(/[\r\n]+/g, " ").slice(0, 200), text, }); } finally { transport.close(); } } type Queued = { id: number; subject: string; href: string; attempts: number }; let running: Promise | null = null; /** Sends every queued email that is due (ยง24); never overlaps itself. */ export function deliverDue(kepr: Database, config: Config, now = Date.now()): Promise { running ??= deliver(kepr, config, Math.floor(now / 1000)).finally(() => { running = null; }); return running; } async function deliver(kepr: Database, config: Config, now: number): Promise { const due = kepr .query( `SELECT id, subject, href, attempts FROM email_queue WHERE status = 'waiting' AND next_at <= ? ORDER BY id`, ) .all(now); for (const email of due) { try { if (!config.smtp) throw new Error("no [smtp] settings in kepr.toml"); const link = `https://${config.domain}${email.href}`; await sendMail(config.smtp, email.subject, `${email.subject}\n\n${link}\n`); kepr .query("UPDATE email_queue SET status = 'sent', attempts = ?, sent_at = ? WHERE id = ?") .run(email.attempts + 1, now, email.id); } catch (error) { const attempts = email.attempts + 1; const wait = RETRY_MINUTES[attempts - 1]; kepr .query( `UPDATE email_queue SET status = ?, attempts = ?, next_at = ?, last_error = ? WHERE id = ?`, ) .run( wait === undefined ? "failed" : "waiting", attempts, now + (wait ?? 0) * 60, error instanceof Error ? error.message : String(error), email.id, ); } } } export function startMailLoop(kepr: Database, config: Config): void { setInterval(() => { deliverDue(kepr, config).catch((error) => console.error(`kepr: email failed: ${error}`)); }, LOOP_MS); }