0ae59f3550
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
117 lines
3.9 KiB
JavaScript
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 !== "";
|
|
}
|