Default-deny outbound mail and a sign-up gate

This is a test area on a testnet contract, but it was seeded on 15 Sep with a copy of
InstantAdPay's live member list and it carried a working SendGrid key. On 18 Sep the
coach's stall nudges fired on schedule and emailed 95 real people from it. One of them
clicked through and opened a fresh account two hours later.

Nothing was misconfigured. Every send site checked mailer.hasKey(), the key was there,
so every send site got a yes. The default was wrong, not the plumbing.

OUTBOUND=on is now required before anything can leave: hasKey() is false without it,
send() rejects outright, and the two mailing loops (coach nudges, lead drip) never start.
SIGNUPS=closed shuts the three registration doors and reports signupsOpen:false, which
also drops the email-code card from the UI. Members already here keep their dashboards.

A missing env var means silence now, not delivery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 04:29:08 -05:00
parent 00ab223ce6
commit 0374b04f0d
2 changed files with 28 additions and 7 deletions
+6 -1
View File
@@ -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 }] }],
+22 -6
View File
@@ -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); });