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; /** One plain-text email; to the operator unless `to` names someone else. */ export type Mail = { subject: string; text: string; to?: string; unsubscribe?: string }; /** Sends one email now; throws with the server's answer on failure. */ export async function sendMail(smtp: SmtpSettings, mail: Mail): 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: mail.to ?? smtp.to, // Issue titles come from strangers: one line, and short enough for any inbox. subject: mail.subject.replace(/[\r\n]+/g, " ").slice(0, 200), text: mail.text, // RFC 8058's one-click unsubscribe, which inboxes show as their own button. ...(mail.unsubscribe && { headers: { "List-Unsubscribe": `<${mail.unsubscribe}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", }, }), }); } finally { transport.close(); } } type Queued = { id: number; subject: string; href: string; recipient: string; body: string; unsubscribe: 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, recipient, body, unsubscribe, 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 site = `https://${config.domain}`; await sendMail( config.smtp, email.recipient ? { to: email.recipient, subject: email.subject, text: email.body, unsubscribe: email.unsubscribe && `${site}${email.unsubscribe}`, } : { subject: `kepr: ${email.subject}`, text: `${email.subject}\n\n${site}${email.href}\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); }