import { Router } from "express"; import { pool } from "../db.js"; import { submitLimiter } from "../middleware/rate-limit.js"; import { createLead } from "../erpnext-client.js"; // Listmonk subscriber push (non-blocking) const LISTMONK_URL = process.env.LISTMONK_URL || "http://listmonk:9000"; const LISTMONK_USER = process.env.LISTMONK_USER || "api"; const LISTMONK_PASS = process.env.LISTMONK_PASSWORD || ""; async function addToListmonk(email: string, name: string, company?: string) { const resp = await fetch(`${LISTMONK_URL}/api/subscribers`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Basic ${Buffer.from(`${LISTMONK_USER}:${LISTMONK_PASS}`).toString("base64")}`, }, body: JSON.stringify({ email, name: name || email.split("@")[0], status: "enabled", lists: [3], // FCC Carriers - Direct Contacts preconfirm_subscriptions: true, attribs: { company: company || "", source: "website" }, }), }); if (!resp.ok && resp.status !== 409) { throw new Error(`Listmonk ${resp.status}: ${await resp.text()}`); } } const router = Router(); const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const DISPOSABLE_DOMAINS = new Set([ "mailinator.com", "guerrillamail.com", "tempmail.com", "throwaway.email", "yopmail.com", "sharklasers.com", "guerrillamailblock.com", "grr.la", "dispostable.com", "trashmail.com", "fakeinbox.com", "temp-mail.org", ]); const CONSENT_TEXT = "I agree to receive compliance updates and service announcements from Performance West Inc. I can unsubscribe at any time."; // POST /api/v1/subscribe router.post("/api/v1/subscribe", submitLimiter, async (req, res) => { try { const { email, name, company, consent, _hp, _ts } = req.body ?? {}; // Honeypot — bots fill hidden fields if (_hp) { res.status(201).json({ success: true, message: "Subscribed." }); return; } // Timing check — reject if form submitted in < 2 seconds if (_ts && typeof _ts === "number" && Date.now() - _ts < 2_000) { res.status(201).json({ success: true, message: "Subscribed." }); return; } if (!email || typeof email !== "string" || !EMAIL_RE.test(email)) { res.status(400).json({ error: "Valid email address is required." }); return; } if (!consent) { res.status(400).json({ error: "Consent is required to subscribe." }); return; } // Block disposable email domains const domain = email.split("@")[1]?.toLowerCase(); if (domain && DISPOSABLE_DOMAINS.has(domain)) { res.status(400).json({ error: "Please use a permanent email address." }); return; } const ip = (req as any).clientIp || req.ip || ""; await pool.query( `INSERT INTO subscribers (email, name, company, consent_text, consent_at, ip_address, source) VALUES ($1, $2, $3, $4, NOW(), $5, 'website') ON CONFLICT (email) DO UPDATE SET unsubscribed = FALSE, consent_text = $4, consent_at = NOW(), ip_address = $5`, [email.toLowerCase().trim(), name || null, company || null, CONSENT_TEXT, ip], ); // Push to ERPNext (Lead) and Listmonk — non-blocking, don't fail the response const cleanEmail = email.toLowerCase().trim(); const [firstName, ...lastParts] = (name || "").trim().split(/\s+/); const lastName = lastParts.join(" ") || ""; try { await createLead({ name: (name || cleanEmail).trim(), email: cleanEmail, company: company || undefined, source: "Website", notes: `Subscribed via website. Consent: "${CONSENT_TEXT}"`, }); } catch (erpErr) { console.error("[subscribe] ERPNext createLead failed (non-fatal):", erpErr); } try { await addToListmonk(cleanEmail, name || cleanEmail, company || undefined); } catch (listmonkErr) { console.error("[subscribe] Listmonk addToListmonk failed (non-fatal):", listmonkErr); } res.status(201).json({ success: true, message: "You're on the list." }); } catch (err) { console.error("[subscribe] Error:", err); res.status(500).json({ error: "Subscription failed. Please try again." }); } }); // POST /api/v1/unsubscribe router.post("/api/v1/unsubscribe", async (req, res) => { try { const { email } = req.body ?? {}; if (!email || typeof email !== "string") { res.status(400).json({ error: "Email is required." }); return; } await pool.query( "UPDATE subscribers SET unsubscribed = TRUE WHERE email = $1", [email.toLowerCase().trim()], ); res.status(200).json({ success: true, message: "You have been unsubscribed." }); } catch (err) { console.error("[unsubscribe] Error:", err); res.status(500).json({ error: "Unsubscribe failed. Please try again." }); } }); export default router;