mail.ts

3.5 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/** One plain-text email; to the operator unless `to` names someone else. */11export type Mail = { subject: string; text: string; to?: string; unsubscribe?: string };1213/** Sends one email now; throws with the server's answer on failure. */14export async function sendMail(smtp: SmtpSettings, mail: Mail): Promise<void> {15	const transport = createTransport({16		host: smtp.host,17		port: smtp.port,18		secure: smtp.security === "tls",19		requireTLS: smtp.security === "starttls",20		ignoreTLS: smtp.security === "none",21		...(smtp.username ? { auth: { user: smtp.username, pass: smtp.password } } : {}),22		connectionTimeout: TIMEOUT_MS,23		greetingTimeout: TIMEOUT_MS,24		socketTimeout: TIMEOUT_MS,25	});26	try {27		await transport.sendMail({28			from: smtp.from,29			to: mail.to ?? smtp.to,30			// Issue titles come from strangers: one line, and short enough for any inbox.31			subject: mail.subject.replace(/[\r\n]+/g, " ").slice(0, 200),32			text: mail.text,33			// RFC 8058's one-click unsubscribe, which inboxes show as their own button.34			...(mail.unsubscribe && {35				headers: {36					"List-Unsubscribe": `<${mail.unsubscribe}>`,37					"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",38				},39			}),40		});41	} finally {42		transport.close();43	}44}4546type Queued = {47	id: number;48	subject: string;49	href: string;50	recipient: string;51	body: string;52	unsubscribe: string;53	attempts: number;54};5556let running: Promise<void> | null = null;5758/** Sends every queued email that is due (§24); never overlaps itself. */59export function deliverDue(kepr: Database, config: Config, now = Date.now()): Promise<void> {60	running ??= deliver(kepr, config, Math.floor(now / 1000)).finally(() => {61		running = null;62	});63	return running;64}6566async function deliver(kepr: Database, config: Config, now: number): Promise<void> {67	const due = kepr68		.query<Queued, [number]>(69			`SELECT id, subject, href, recipient, body, unsubscribe, attempts FROM email_queue70			WHERE status = 'waiting' AND next_at <= ?71			ORDER BY id`,72		)73		.all(now);74	for (const email of due) {75		try {76			if (!config.smtp) throw new Error("no [smtp] settings in kepr.toml");77			const site = `https://${config.domain}`;78			await sendMail(79				config.smtp,80				email.recipient81					? {82							to: email.recipient,83							subject: email.subject,84							text: email.body,85							unsubscribe: email.unsubscribe && `${site}${email.unsubscribe}`,86						}87					: {88							subject: `kepr: ${email.subject}`,89							text: `${email.subject}\n\n${site}${email.href}\n`,90						},91			);92			kepr93				.query("UPDATE email_queue SET status = 'sent', attempts = ?, sent_at = ? WHERE id = ?")94				.run(email.attempts + 1, now, email.id);95		} catch (error) {96			const attempts = email.attempts + 1;97			const wait = RETRY_MINUTES[attempts - 1];98			kepr99				.query(100					`UPDATE email_queue SET status = ?, attempts = ?, next_at = ?, last_error = ?101					WHERE id = ?`,102				)103				.run(104					wait === undefined ? "failed" : "waiting",105					attempts,106					now + (wait ?? 0) * 60,107					error instanceof Error ? error.message : String(error),108					email.id,109				);110		}111	}112}113114export function startMailLoop(kepr: Database, config: Config): void {115	setInterval(() => {116		deliverDue(kepr, config).catch((error) => console.error(`kepr: email failed: ${error}`));117	}, LOOP_MS);118}