1import type { Database } from "bun:sqlite";2import { createTransport } from "nodemailer";3import type { Config, SmtpSettings } from "./config.ts";45const LOOP_MS = 60 * 1000;67const RETRY_MINUTES = [1, 5, 30, 120, 720];8const TIMEOUT_MS = 15 * 1000;91011export type Mail = { subject: string; text: string; to?: string; unsubscribe?: string };121314export 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 31 subject: mail.subject.replace(/[\r\n]+/g, " ").slice(0, 200),32 text: mail.text,33 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;575859export 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}