// SMTP transport for outbound mail (currently: magic-link sign-in // links). Credentials come from /data/config/startos-config.json, // which startos/main.ts keeps in sync with the StartOS System SMTP // config via effects.getSystemSmtp({callback}). // // We poll the same JSON the rest of the server reads (via // getConfigSnapshot()) and rebuild the nodemailer transport whenever // the credentials change. Rebuilds are cheap — nodemailer's // createTransport is synchronous and just stashes options. The actual // SMTP connection is opened per-send (or pooled), not at transport // creation. // // Only used in multi-tenant mode. Single mode never imports this. import nodemailer from "nodemailer"; import { getConfigSnapshot } from "./config.js"; let cachedTransport = null; let cachedFingerprint = ""; // last seen credentials as a stable string let cachedFrom = ""; const POLL_MS = 3000; // initSmtp(): kicks off the credentials-watch loop. Idempotent in // effect (later calls are harmless), but you only need it once per // process lifetime. export function initSmtp() { refreshTransport().catch((err) => { console.warn("[smtp] initial refresh failed:", err); }); setInterval(() => { refreshTransport().catch(() => {}); }, POLL_MS); } async function refreshTransport() { const snap = await getConfigSnapshot(); const host = snap.smtp_host || ""; const port = parseInt(snap.smtp_port || 0, 10) || 0; const security = snap.smtp_security || "tls"; const username = snap.smtp_username || ""; const password = snap.smtp_password || ""; const from = snap.smtp_from || ""; // Fingerprint covers every field that affects the transport. If // nothing changed, leave the existing transport in place — avoids // tearing down a pooled connection on every poll tick. const fingerprint = [host, port, security, username, password, from].join( "\x1f", ); if (fingerprint === cachedFingerprint) return; cachedFingerprint = fingerprint; cachedFrom = from; if (!host || !port) { if (cachedTransport) { cachedTransport.close?.(); cachedTransport = null; console.log("[smtp] credentials cleared, transport closed"); } return; } // security mapping: // - "tls" → implicit TLS (SMTPS, typically port 465). secure=true. // - "starttls" → cleartext then STARTTLS (typically port 587). secure=false, // requireTLS=true so we refuse to silently fall back to plain. const isImplicitTls = security === "tls"; const transportOpts = { host, port, secure: isImplicitTls, requireTLS: !isImplicitTls, // forces STARTTLS when secure=false auth: username ? { user: username, pass: password } : undefined, }; try { const next = nodemailer.createTransport(transportOpts); if (cachedTransport) cachedTransport.close?.(); cachedTransport = next; console.log( `[smtp] transport built host=${host} port=${port} security=${security} user=${username || "(none)"}`, ); } catch (err) { console.warn("[smtp] createTransport failed:", err); cachedTransport = null; } } // sendMail({to, subject, text, html}) // Returns nodemailer's info object on success. Throws if the // transport isn't configured — callers should treat that as "SMTP // not set up yet, ask the operator to configure StartOS System SMTP." export async function sendMail({ to, subject, text, html }) { if (!cachedTransport) { throw new Error("smtp_not_configured"); } if (!cachedFrom) { throw new Error("smtp_from_not_set"); } return await cachedTransport.sendMail({ from: cachedFrom, to, subject, text, html, }); } // True iff the transport is built and ready to send. The auth handlers // use this to gate magic-link requests with a useful error rather than // letting nodemailer's "no transport" leak through. export function isSmtpReady() { return cachedTransport !== null && cachedFrom !== ""; }