← back to Coming Soon Template
email-signup-server.js
154 lines
#!/usr/bin/env node
/**
* Shared email-signup endpoint for the 21 Gucci-coming-soon pages.
*
* Listens at POST /api/email-signup
* body: { email, source, timestamp }
* returns: 200 { ok:true } | 400 { error } | 429 { error } | 500
*
* Storage: append-only JSONL at ~/data/email-signups.jsonl (or DATA_FILE env).
* Rate-limit: max 1 signup per IP per email per 60s (in-memory window).
* CORS: allows the 21 known domains + agentabrams.com itself.
*
* Designed to run on Kamatera behind nginx at agentabrams.com/api/email-signup.
* Zero deps. Bind defaults to 127.0.0.1:9697 (override with PORT) so nginx
* proxies in; never expose this port publicly.
*
* Future: ELEVENLABS-free Steve-voice confirmation, webhook to Postgres, etc.
*/
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = parseInt(process.env.PORT, 10) || 9697;
const HOST = process.env.HOST || '127.0.0.1';
const DATA_FILE = process.env.DATA_FILE || path.join(process.env.HOME || '/root', 'data', 'email-signups.jsonl');
const ALLOWED_ORIGINS = new Set([
'https://agentabrams.com',
// abrams.* cluster
'https://abramsos.com', 'https://abramsintel.com', 'https://abramsintelligence.com',
'https://abramsspace.com', 'https://abramsterminal.com', 'https://abramsvc.com',
'https://abramsindustries.com', 'https://abramsprotection.com', 'https://abramscivic.com',
'https://abramslive.com', 'https://abramsatlas.com', 'https://abramsdirectory.com',
'https://abramsguide.com', 'https://abramsindex.com', 'https://abramslocal.com',
'https://abramsmaps.com', 'https://abramsmarkets.com',
// butler cluster
'https://818butler.com', 'https://beverlyhillsbutler.com',
'https://bhbutler.com', 'https://boulevardbutler.com',
]);
// Rate-limit window (in-memory)
const WINDOW_MS = 60_000;
const seen = new Map(); // key = ip|email → expiresAt
function readBody(req, max = 4096) {
return new Promise((resolve, reject) => {
let total = 0;
const chunks = [];
req.on('data', c => {
total += c.length;
if (total > max) { req.destroy(); reject(new Error('payload too large')); return; }
chunks.push(c);
});
req.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); }
catch { reject(new Error('invalid json')); }
});
req.on('error', reject);
});
}
function ipOf(req) {
return (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| (req.socket && req.socket.remoteAddress) || 'unknown';
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function send(res, status, body, extraHeaders) {
const headers = Object.assign({
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
}, extraHeaders || {});
res.writeHead(status, headers);
res.end(typeof body === 'string' ? body : JSON.stringify(body));
}
function corsHeaders(origin) {
if (origin && ALLOWED_ORIGINS.has(origin)) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '600',
'Vary': 'Origin',
};
}
return { 'Vary': 'Origin' };
}
function ensureDataDir() {
const dir = path.dirname(DATA_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
ensureDataDir();
const server = http.createServer(async (req, res) => {
const origin = req.headers.origin;
const cors = corsHeaders(origin);
if (req.method === 'OPTIONS') { send(res, 204, '', cors); return; }
if (req.method === 'GET' && req.url === '/api/health') {
return send(res, 200, { ok: true, signups_file: DATA_FILE }, cors);
}
if (req.method !== 'POST' || req.url !== '/api/email-signup') {
return send(res, 404, { error: 'not found' }, cors);
}
let body;
try { body = await readBody(req); }
catch (e) { return send(res, 400, { error: e.message }, cors); }
const email = String(body.email || '').trim().toLowerCase();
const source = String(body.source || '').trim().toLowerCase();
const ts = body.timestamp ? String(body.timestamp).slice(0, 40) : new Date().toISOString();
if (!email || !EMAIL_RE.test(email)) return send(res, 400, { error: 'invalid email' }, cors);
if (!source) return send(res, 400, { error: 'missing source' }, cors);
if (email.length > 320 || source.length > 80) return send(res, 400, { error: 'fields too long' }, cors);
const ip = ipOf(req);
const key = ip + '|' + email;
const now = Date.now();
// Sweep expired entries cheaply (every ~100 requests this loops; otherwise just delete on hit)
if (seen.has(key) && seen.get(key) > now) {
return send(res, 429, { error: 'duplicate within window' }, cors);
}
seen.set(key, now + WINDOW_MS);
if (seen.size > 5000) {
for (const [k, exp] of seen) if (exp <= now) seen.delete(k);
}
const row = { email, source, timestamp: ts, ip, ua: String(req.headers['user-agent'] || '').slice(0, 200) };
try {
fs.appendFileSync(DATA_FILE, JSON.stringify(row) + '\n');
} catch (e) {
console.error('append failed:', e.message);
return send(res, 500, { error: 'storage failure' }, cors);
}
send(res, 200, { ok: true }, cors);
});
server.listen(PORT, HOST, () => {
console.log(`email-signup listening on http://${HOST}:${PORT}/api/email-signup`);
console.log(`writing to ${DATA_FILE}`);
});