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:
@@ -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 }] }],
|
||||
|
||||
@@ -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 });
|
||||
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);
|
||||
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); });
|
||||
|
||||
Reference in New Issue
Block a user