/** * Email sending utility using nodemailer. * Non-blocking — errors are logged, not thrown. */ import nodemailer from "nodemailer"; import { prisma as db } from "@capakraken/db"; import { logger } from "./logger.js"; import { resolveSystemSettingsRuntime } from "./system-settings-runtime.js"; interface EmailPayload { to: string | string[]; subject: string; text: string; html?: string; } function sanitizeSmtpDiagnostic(err: unknown): string { const message = err instanceof Error ? err.message : String(err); return message .replace(/https?:\/\/[^\s)\]}]+/gi, "") .replace(/\b[^\s@]+@[^\s@]+\.[^\s@]+\b/g, "") .replace(/\b(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}\b/g, "") .replace(/^Error:\s*/, "") .slice(0, 300); } export function parseSmtpError(err: unknown): string { const message = err instanceof Error ? err.message : String(err); const lower = message.toLowerCase(); if (lower.includes("auth") || lower.includes("invalid login") || lower.includes("535")) { return "SMTP authentication failed — check username and password."; } if (lower.includes("certificate") || lower.includes("tls") || lower.includes("ssl")) { return "SMTP TLS negotiation failed — verify port and TLS settings."; } if ( lower.includes("econnrefused") || lower.includes("enotfound") || lower.includes("etimedout") || lower.includes("timeout") ) { return "Cannot reach the SMTP server — check host, port, and network access."; } return "SMTP connection failed — review host, port, TLS, and credentials."; } async function getSmtpConfig() { const settings = resolveSystemSettingsRuntime( await db.systemSettings.findUnique({ where: { id: "singleton" } }), ); if (!settings.smtpHost) return null; const port = typeof settings.smtpPort === "number" ? settings.smtpPort : settings.smtpPort !== null && settings.smtpPort !== undefined ? parseInt(String(settings.smtpPort), 10) : 587; return { host: settings.smtpHost, port, secure: settings.smtpTls === false ? false : true, auth: settings.smtpUser && settings.smtpPassword ? { user: settings.smtpUser, pass: settings.smtpPassword } : undefined, from: settings.smtpFrom ?? settings.smtpUser ?? "noreply@capakraken.app", }; } /** * Send an email. Swallows errors so calling code is never blocked. * Returns true if sent successfully. */ export async function sendEmail(payload: EmailPayload): Promise { try { const config = await getSmtpConfig(); if (!config) return false; const transporter = nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: config.auth, }); await transporter.sendMail({ from: config.from, to: Array.isArray(payload.to) ? payload.to.join(", ") : payload.to, subject: payload.subject, text: payload.text, html: payload.html, }); return true; } catch (err) { logger.warn({ diagnostic: sanitizeSmtpDiagnostic(err) }, "Email send failed"); return false; } } /** * Test SMTP connection. Returns { ok: boolean; error?: string }. */ export async function testSmtpConnection(): Promise<{ ok: boolean; error?: string }> { try { const config = await getSmtpConfig(); if (!config) return { ok: false, error: "SMTP not configured" }; const transporter = nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: config.auth, }); await transporter.verify(); return { ok: true }; } catch (err) { logger.warn({ diagnostic: sanitizeSmtpDiagnostic(err) }, "SMTP connection test failed"); return { ok: false, error: parseSmtpError(err) }; } }