← 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}`);
});