Files
recap/server/smtp.js
T
Keysat 0ae59f3550 Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
2026-06-13 14:25:05 -05:00

117 lines
3.9 KiB
JavaScript

// 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 !== "";
}