new-site/api/src/routes/subscribe.ts
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

142 lines
4.8 KiB
TypeScript

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;