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
This commit is contained in:
+116
@@ -0,0 +1,116 @@
|
||||
// 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 !== "";
|
||||
}
|
||||
Reference in New Issue
Block a user