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>
142 lines
4.8 KiB
TypeScript
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;
|