mail.ts

2.8 KB · raw

1import type { Database } from "bun:sqlite";2import { createTransport } from "nodemailer";3import type { Config, SmtpSettings } from "./config.ts";45const LOOP_MS = 60 * 1000;6// After the first attempt fails, the waits before each retry; then the email is marked failed.7const RETRY_MINUTES = [1, 5, 30, 120, 720];8const TIMEOUT_MS = 15 * 1000;910/** Sends one plain-text email to the operator, now; throws with the server's answer on failure. */11export async function sendMail(smtp: SmtpSettings, subject: string, text: string): Promise<void> {12	const transport = createTransport({13		host: smtp.host,14		port: smtp.port,15		secure: smtp.security === "tls",16		requireTLS: smtp.security === "starttls",17		ignoreTLS: smtp.security === "none",18		...(smtp.username ? { auth: { user: smtp.username, pass: smtp.password } } : {}),19		connectionTimeout: TIMEOUT_MS,20		greetingTimeout: TIMEOUT_MS,21		socketTimeout: TIMEOUT_MS,22	});23	try {24		await transport.sendMail({25			from: smtp.from,26			to: smtp.to,27			// Issue titles come from strangers: one line, and short enough for any inbox.28			subject: `kepr: ${subject}`.replace(/[\r\n]+/g, " ").slice(0, 200),29			text,30		});31	} finally {32		transport.close();33	}34}3536type Queued = { id: number; subject: string; href: string; attempts: number };3738let running: Promise<void> | null = null;3940/** Sends every queued email that is due (§24); never overlaps itself. */41export function deliverDue(kepr: Database, config: Config, now = Date.now()): Promise<void> {42	running ??= deliver(kepr, config, Math.floor(now / 1000)).finally(() => {43		running = null;44	});45	return running;46}4748async function deliver(kepr: Database, config: Config, now: number): Promise<void> {49	const due = kepr50		.query<Queued, [number]>(51			`SELECT id, subject, href, attempts FROM email_queue52			WHERE status = 'waiting' AND next_at <= ?53			ORDER BY id`,54		)55		.all(now);56	for (const email of due) {57		try {58			if (!config.smtp) throw new Error("no [smtp] settings in kepr.toml");59			const link = `https://${config.domain}${email.href}`;60			await sendMail(config.smtp, email.subject, `${email.subject}\n\n${link}\n`);61			kepr62				.query("UPDATE email_queue SET status = 'sent', attempts = ?, sent_at = ? WHERE id = ?")63				.run(email.attempts + 1, now, email.id);64		} catch (error) {65			const attempts = email.attempts + 1;66			const wait = RETRY_MINUTES[attempts - 1];67			kepr68				.query(69					`UPDATE email_queue SET status = ?, attempts = ?, next_at = ?, last_error = ?70					WHERE id = ?`,71				)72				.run(73					wait === undefined ? "failed" : "waiting",74					attempts,75					now + (wait ?? 0) * 60,76					error instanceof Error ? error.message : String(error),77					email.id,78				);79		}80	}81}8283export function startMailLoop(kepr: Database, config: Config): void {84	setInterval(() => {85		deliverDue(kepr, config).catch((error) => console.error(`kepr: email failed: ${error}`));86	}, LOOP_MS);87}