diff --git a/mailer.js b/mailer.js index 3df44c8..7f6000c 100644 --- a/mailer.js +++ b/mailer.js @@ -13,9 +13,14 @@ function key() { if (process.env.SENDGRID_KEY) return process.env.SENDGRID_KEY.trim(); try { return fs.readFileSync(path.join(DATA_DIR, 'sendgrid.key'), 'utf8').trim(); } catch (e) { return ''; } } -function hasKey() { return !!key(); } +// A deployment must opt IN to sending mail. Default-deny, on purpose: on 2026-09-18 this +// test area mailed 95 real people because a seeded member list and a live key were both +// present and every send site dutifully checked hasKey() and got a yes. +const OUTBOUND = process.env.OUTBOUND === 'on'; +function hasKey() { return OUTBOUND && !!key(); } function send(to, subject, text) { + if (!OUTBOUND) return Promise.reject(new Error('outbound mail is off on this deployment (set OUTBOUND=on to allow it)')); return new Promise((resolve, reject) => { const body = JSON.stringify({ personalizations: [{ to: [{ email: to }] }], diff --git a/server.js b/server.js index f9c894b..c2f2acb 100644 --- a/server.js +++ b/server.js @@ -78,6 +78,12 @@ function dropAdminSession(req) { const t = adminTokenOf(req); if (t && adminSess function adminCookie(t) { return 'iap.adm=' + encodeURIComponent(t) + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + (ADMIN_TTL / 1000) + (IS_PROD ? '; Secure' : ''); } function clearAdminCookie() { return 'iap.adm=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; } const IS_PROD = process.env.NODE_ENV === 'production'; +// Posture gates. mailer.js is the authority on outbound; this copy only decides whether the +// mailing loops are worth starting. SIGNUPS=closed shuts registration without taking the +// site down — members already here keep their dashboards. +const OUTBOUND_MAIL = process.env.OUTBOUND === 'on'; +const SIGNUPS_OPEN = process.env.SIGNUPS !== 'closed'; +const SHUT = { error: 'LinkSpin is not accepting new accounts right now.' }; const SITE_FILE = path.join(DATA_DIR, 'site.json'); const db = require('./db'); @@ -405,12 +411,16 @@ async function boot() { setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily setInterval(() => tank.sweep().catch(e => console.error('tank sweep', e.message)), 60 * 60 * 1000); // adoptions past their 7-day window burner.init({ chain, ads, accounts }); - setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000); - setInterval(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 60 * 60 * 1000); + if (OUTBOUND_MAIL) { // the stall nudges are a mailing loop; do not even start it when mail is off + setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000); + setInterval(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 60 * 60 * 1000); + } setTimeout(() => burner.tick().catch(e => console.error('burner', e.message)), 45 * 1000); setInterval(() => burner.tick().catch(e => console.error('burner', e.message)), 5 * 60 * 1000); - setTimeout(() => drip.tick().catch(e => console.error('drip', e.message)), 30 * 1000); - setInterval(() => drip.tick().catch(e => console.error('drip', e.message)), 10 * 60 * 1000); + if (OUTBOUND_MAIL) { // same for the lead follow-up sequence + setTimeout(() => drip.tick().catch(e => console.error('drip', e.message)), 30 * 1000); + setInterval(() => drip.tick().catch(e => console.error('drip', e.message)), 10 * 60 * 1000); + } // NAS reconcile: pull syndicated delivery into the unified credit pool // (inert unless NAS_DB_* is set). Every 5 min after a short warm-up. if (ads.nasEnabled()) { @@ -882,7 +892,8 @@ const server = http.createServer(async (req, res) => { for (const [k, v] of Object.entries(siteConfig())) if (!/secret|token|password|private|apikey|api_key/i.test(k)) pubSite[k] = v; return json(res, 200, Object.assign({ contract: c.contract, chainId: c.chainId, chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0], - emailAuth: mailer.hasKey() || !IS_PROD }, pubSite)); + emailAuth: (mailer.hasKey() || !IS_PROD) && SIGNUPS_OPEN, + signupsOpen: SIGNUPS_OPEN }, pubSite)); } if (p === '/api/moonpay-url' && req.method === 'GET') { // Card on-ramp deep link. With MoonPay keys set — PUBLIC key via @@ -1005,6 +1016,7 @@ const server = http.createServer(async (req, res) => { // -- accounts: email + password is the normal join path (wallet comes // out only at purchase / payout-activation time and gets linked then) if (p === '/api/signup' && req.method === 'POST') { + if (!SIGNUPS_OPEN) return json(res, 403, SHUT); const b = await readBody(req); const ref = parseCookies(req)['iap.sponsor'] || ''; // last-touch attribution, locked at account creation const r = await accounts.signup(b.email, b.password, ref); @@ -1027,6 +1039,7 @@ const server = http.createServer(async (req, res) => { // -- passwordless: email code sign-in (signup and login are the same act) if (p === '/api/auth/email/start' && req.method === 'POST') { + if (!SIGNUPS_OPEN) return json(res, 403, SHUT); const b = await readBody(req); const e = String(b.email || '').trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' }); @@ -1058,6 +1071,8 @@ const server = http.createServer(async (req, res) => { const ref = parseCookies(req)['iap.sponsor'] || ''; const via = parseCookies(req)['iap.angle'] || ''; const joinedRef = decodeURIComponent(parseCookies(req)['iap.ref'] || '') || null; + // the code door creates an account on first use, so it is a sign-up door too + if (!SIGNUPS_OPEN && !(await accounts.byEmail(e))) return json(res, 403, SHUT); const r = await accounts.ensure(e, ref, via, joinedRef); // first touch wins; existing accounts unchanged if (r.error) return json(res, 400, r); if (r.created) carry.onJoin(r.account, !!ref).catch(() => {}); // network sponsor carries over when no link was used (LinkSpin) @@ -2861,5 +2876,6 @@ const server = http.createServer(async (req, res) => { } }); boot().then(() => { - server.listen(PORT, () => console.log(`LinkSpin site on :${PORT} — chain: ${chain.getConfig().chainName} — store: ${db.enabled() ? 'MySQL' : 'volume JSON'}`)); + server.listen(PORT, () => console.log(`LinkSpin site on :${PORT} — chain: ${chain.getConfig().chainName} — store: ${db.enabled() ? 'MySQL' : 'volume JSON'}` + + ` — outbound mail: ${OUTBOUND_MAIL ? 'ON' : 'OFF'} — sign-ups: ${SIGNUPS_OPEN ? 'open' : 'CLOSED'}`)); }).catch(e => { console.error('boot failed:', e.message); process.exit(1); });