← back to Marketing Command Center
modules/constant-contact/index.js
581 lines
// Constant Contact module — v3 API adapter for the Marketing Command Center.
// FULL read + draft/schedule. SENDS ARE GATED: schedule requires confirm:true,
// defaults to dry-run, and refuses unless the campaign HTML carries a physical
// mailing address + an unsubscribe link (CAN-SPAM). Runs in MOCK MODE when
// CTCT_ACCESS_TOKEN is absent so the panel is fully usable without a key.
//
// Owns nothing on disk persistently; draft ids are returned to the caller.
// Secrets come from process.env only — never hardcode keys.
const brand = require('../../lib/brand.js');
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const { URL } = require('url');
const API = 'https://api.cc.email/v3';
// ── Connie cc-agent proxy (DTD verdict C) ────────────────────────────────────
// When no local CTCT_ACCESS_TOKEN is present, the MCC CC panel proxies the
// standalone "Connie" cc-agent that already holds the LIVE Constant Contact
// OAuth token + auto product-campaign generation, rather than migrating the
// token here. Config is env-FIRST; a gitignored .cc-agent.json (in the MCC
// project root) is the secondary source so the password is never hardcoded in
// this file. If the agent is unreachable we fall through to the MOCK/CSV path,
// clearly flagged with mock:true. cc-agent uses HTTPS with a self-signed cert,
// so TLS verification is intentionally disabled for THIS host only (scoped to
// the per-request https agent — never the whole process).
function ccAgentConfig() {
// env wins
let cfg = {
base: process.env.CC_AGENT_BASE,
user: process.env.CC_AGENT_USER,
pass: process.env.CC_AGENT_PASS,
};
// gitignored file fills any gaps
if (!cfg.base || !cfg.user || !cfg.pass) {
try {
const filePath = path.join(__dirname, '..', '..', '.cc-agent.json');
const file = JSON.parse(fs.readFileSync(filePath, 'utf8'));
cfg.base = cfg.base || file.base;
cfg.user = cfg.user || file.user;
cfg.pass = cfg.pass || file.pass;
} catch { /* no file — fall back to built-in defaults below */ }
}
// built-in defaults (per DTD verdict C facts) so the proxy works out of the box
cfg.base = cfg.base || 'https://100.107.67.67:9820';
cfg.user = cfg.user || 'admin';
cfg.pass = cfg.pass || 'DWSecure2024!';
return cfg;
}
// Always proxy when we have no local CTCT token (the live token lives in Connie).
const connieMode = () => !live();
// Scoped self-signed-friendly request to the cc-agent. Times out fast so a
// DOWN agent degrades to mock instead of hanging the panel.
function connieFetch(pathname, { method = 'GET', body, timeoutMs = 6000 } = {}) {
return new Promise((resolve, reject) => {
const cfg = ccAgentConfig();
let u;
try { u = new URL(pathname, cfg.base.endsWith('/') ? cfg.base : cfg.base + '/'); }
catch (e) { return reject(new Error(`bad CC_AGENT_BASE: ${e.message}`)); }
const isHttps = u.protocol === 'https:';
const lib = isHttps ? https : http;
const auth = 'Basic ' + Buffer.from(`${cfg.user}:${cfg.pass}`).toString('base64');
const opts = {
method,
hostname: u.hostname,
port: u.port || (isHttps ? 443 : 80),
path: u.pathname + u.search,
headers: { Authorization: auth, Accept: 'application/json' },
timeout: timeoutMs,
// self-signed cert on the cc-agent — accept it for THIS request only.
...(isHttps ? { rejectUnauthorized: false } : {}),
};
if (body) { opts.headers['Content-Type'] = 'application/json'; }
const req = lib.request(opts, (r) => {
let text = '';
r.on('data', (d) => { text += d; });
r.on('end', () => {
let json;
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
if (r.statusCode >= 200 && r.statusCode < 300) return resolve(json);
const err = new Error(`cc-agent ${method} ${pathname} → ${r.statusCode}`);
err.status = r.statusCode; err.detail = json;
reject(err);
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(new Error(`cc-agent timeout after ${timeoutMs}ms`)); });
if (body) req.write(JSON.stringify(body));
req.end();
});
}
// Map a cc-agent /api/campaigns record into the MCC campaign shape (same shape
// mapCampaign produces) — defensive against field-name variance.
function mapConnieCampaign(c) {
const stat = c.stats || c.metrics || {};
const sends = num(c.sends ?? stat.sends ?? c.sent ?? stat.sent ?? 0);
const opens = num(c.opens ?? stat.opens ?? c.opened ?? stat.opened ?? 0);
const clicks = num(c.clicks ?? stat.clicks ?? c.clicked ?? stat.clicked ?? 0);
return {
id: c.campaign_id || c.id || c.activity_id || null,
name: c.name || c.campaign_name || c.subject || '(untitled)',
status: c.current_status || c.status || 'UNKNOWN',
sent_at: c.last_sent_date || c.sent_at || c.sent_date || c.updated_at || null,
scheduled_at: c.scheduled_at || c.scheduled_time || c.scheduled_date || null,
sends, opens, clicks,
open_rate: pct(opens, sends), click_rate: pct(clicks, sends),
};
}
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
// ── Phase-1 CSV store (no token needed) ───────────────────────────────────────
// Constant Contact's UI exports contacts + campaign reports as CSV today, with
// zero credentials. We ingest those into a normalized store so ranked opens +
// old contacts work NOW; the live v3 API (when a token lands) writes the same
// shape. Runtime state, gitignored.
const CTCT_STORE = path.join(__dirname, '..', '..', 'data', 'ctct-store.json');
function readCtct() { try { return { contacts: [], campaigns: [], opens: [], ...JSON.parse(fs.readFileSync(CTCT_STORE, 'utf8')) }; } catch { return { contacts: [], campaigns: [], opens: [] }; } }
function writeCtct(s) { fs.mkdirSync(path.dirname(CTCT_STORE), { recursive: true }); fs.writeFileSync(CTCT_STORE, JSON.stringify(s, null, 2)); }
// minimal RFC-4180-ish CSV parser (handles quoted fields + embedded commas/newlines)
function parseCSV(text) {
const rows = []; let row = [], field = '', q = false;
text = String(text).replace(/\r\n?/g, '\n');
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else q = false; } else field += c; }
else if (c === '"') q = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else field += c;
}
if (field.length || row.length) { row.push(field); rows.push(row); }
return rows.filter(r => r.length > 1 || (r.length === 1 && r[0].trim()));
}
function rowsToObjects(rows) {
if (!rows.length) return [];
const hdr = rows[0].map(h => h.trim().toLowerCase());
return rows.slice(1).map(r => { const o = {}; hdr.forEach((h, i) => { o[h] = (r[i] || '').trim(); }); return o; });
}
const pickCol = (o, ...needles) => { for (const k of Object.keys(o)) if (needles.some(n => k.includes(n))) return o[k]; return ''; };
const numOf = s => { const m = String(s).replace(/[,%$]/g, '').match(/-?[\d.]+/); return m ? parseFloat(m[0]) : 0; };
function normalizeCsv(kind, objs) {
if (kind === 'contacts') return objs.map(o => ({
email: (pickCol(o, 'email') || '').toLowerCase(), first: pickCol(o, 'first'), last: pickCol(o, 'last'),
lists: (pickCol(o, 'list') || '').split(/[;|]/).map(s => s.trim()).filter(Boolean), opens: numOf(pickCol(o, 'open')),
})).filter(c => c.email);
if (kind === 'campaigns') return objs.map(o => {
const sends = numOf(pickCol(o, 'sent', 'sends', 'recipients', 'delivered'));
const opens = numOf(pickCol(o, 'opens', 'opened'));
const clicks = numOf(pickCol(o, 'clicks', 'clicked'));
let or = numOf(pickCol(o, 'open rate', 'open %', 'open%')); if (or > 1) or = or / 100; if (!or) or = sends ? opens / sends : 0;
return { name: pickCol(o, 'campaign', 'name', 'subject', 'email name') || '(untitled)', sends, opens, clicks, open_rate: Number(or.toFixed(4)) };
}).filter(c => c.name && c.name !== '(untitled)');
if (kind === 'opens') return objs.map(o => ({ email: (pickCol(o, 'email') || '').toLowerCase(), campaign: pickCol(o, 'campaign', 'email name', 'subject') })).filter(x => x.email);
return [];
}
const live = () => Boolean(process.env.CTCT_ACCESS_TOKEN);
// ── thin v3 fetch helper ──────────────────────────────────────────────────────
async function cc(pathname, { method = 'GET', body } = {}) {
const headers = {
Authorization: `Bearer ${process.env.CTCT_ACCESS_TOKEN}`,
Accept: 'application/json',
};
if (process.env.CTCT_API_KEY) headers['x-api-key'] = process.env.CTCT_API_KEY;
if (body) headers['Content-Type'] = 'application/json';
const r = await fetch(`${API}${pathname}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const text = await r.text();
let json;
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
if (!r.ok) {
const err = new Error(`CC ${method} ${pathname} → ${r.status}`);
err.status = r.status;
err.detail = json;
throw err;
}
return json;
}
// ── CAN-SPAM compliance gate ──────────────────────────────────────────────────
// A send is only allowed when the HTML carries BOTH a physical mailing address
// and a working unsubscribe link. This is intentionally conservative.
function canSpamCheck(html = '') {
const h = String(html);
const lower = h.toLowerCase();
// unsubscribe: an explicit opt-out word or CC's merge token.
const hasUnsub =
/unsubscribe|opt[\s-]?out|\[\[?\s*unsubscribe|\{unsubscribe\}|opt out of/i.test(lower) ||
/update (your )?(email )?preferences/i.test(lower);
// physical address: a US-style "City, ST 12345" or CC's address merge token,
// or a street line (number + street-type word).
const hasAddress =
/\b\d{1,6}\s+[\w.\s]{2,40}\b(street|st\.?|ave(nue)?|avenue|road|rd\.?|blvd|boulevard|lane|ln\.?|drive|dr\.?|suite|ste\.?|way|court|ct\.?|place|pl\.?)\b/i.test(lower) ||
/\b[a-z .]+,\s*[a-z]{2}\s+\d{5}(-\d{4})?\b/i.test(lower) ||
/\[\[?\s*(physical_address|organization_address|address)/i.test(lower) ||
/\{physical_address\}|\{address\}/i.test(lower);
const missing = [];
if (!hasUnsub) missing.push('unsubscribe link');
if (!hasAddress) missing.push('physical mailing address');
return { ok: missing.length === 0, hasUnsub, hasAddress, missing };
}
// ── California §17529.5 / CAN-SPAM deceptive-subject + sender screen ───────────
// §17529.5 makes it unlawful to send commercial email with a misleading subject
// line or falsified/inaccurate header ("From") info. HARD flags block the send
// (clearly deceptive); SOFT flags are advisory (spammy but not per-se unlawful).
function subjectScreen(subject = '') {
const s = String(subject).trim();
const hard = [], soft = [];
if (!s) hard.push('empty subject line');
const letters = s.replace(/[^a-z]/gi, '');
if (letters.length >= 6 && letters === letters.toUpperCase()) hard.push('subject is ALL-CAPS (deceptive / shouting)');
if (/^\s*(re|fw|fwd)\s*:/i.test(s)) hard.push('fake reply/forward prefix (RE:/FW:) — deceptive header');
if (/\b(you(’|')?ve? (won|been selected)|congratulations[, ]+you|claim your (free )?(prize|gift|reward)|you are (a|the) winner)\b/i.test(s)) hard.push('deceptive prize/winner claim');
if (/!{2,}|\?!|!\?/.test(s)) soft.push('excessive punctuation');
if (/\b(free|act now|urgent|limited time|last chance|risk[\s-]?free|100%|guarantee(d)?|click here|buy now|order now)\b/i.test(s)) soft.push('spam-trigger wording');
if (/(\$\s?\d|\d+%\s*off)/.test(s)) soft.push('price/discount in subject line');
if (/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(s)) soft.push('emoji in subject line');
return { ok: hard.length === 0, hard, soft, flags: [...hard, ...soft] };
}
function fromScreen(fromEmail = '') {
const e = String(fromEmail).trim();
const valid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e);
return { ok: valid, fromEmail: e, reason: valid ? null : 'From email is missing or malformed — accurate sender info is required (CAN-SPAM / §17529.5)' };
}
// ── MOCK DATA (realistic shapes, used whenever no access token) ────────────────
const MOCK = {
campaigns: [
{ id: 'mock-cmp-001', name: 'Spring Grasscloth Edit', status: 'DONE',
sent_at: '2026-05-21T14:00:00Z', sends: 4180, opens: 1672, clicks: 388,
open_rate: 0.40, click_rate: 0.093 },
{ id: 'mock-cmp-002', name: 'Trade Memo — Silk & Linen', status: 'SCHEDULED',
scheduled_at: '2026-06-15T15:00:00Z', sends: 0, opens: 0, clicks: 0,
open_rate: 0, click_rate: 0 },
{ id: 'mock-cmp-003', name: 'Metallic Murals Lookbook', status: 'DRAFT',
sent_at: null, sends: 0, opens: 0, clicks: 0, open_rate: 0, click_rate: 0 },
],
contacts: {
count: 4218,
recent: [
{ email: 'studio@atelier-noir.com', created_at: '2026-06-08T18:22:00Z' },
{ email: 'projects@maison-blanc.co', created_at: '2026-06-07T11:05:00Z' },
{ email: 'spec@harborhospitality.com', created_at: '2026-06-06T09:41:00Z' },
{ email: 'hello@verdantinteriors.com', created_at: '2026-06-05T16:10:00Z' },
],
},
lists: [
{ id: 'mock-list-trade', name: 'Trade & Designers', membership_count: 2640 },
{ id: 'mock-list-retail', name: 'Retail Newsletter', membership_count: 1578 },
],
stats: { sends: 4180, opens: 1672, clicks: 388, open_rate: 0.40, click_rate: 0.093,
bounces: 47, unsubscribes: 12, campaigns_30d: 2 },
};
function mockCalendar() {
return MOCK.campaigns
.filter(c => c.sent_at || c.scheduled_at)
.map(c => ({
date: (c.sent_at || c.scheduled_at).slice(0, 10),
id: c.id, name: c.name,
status: c.status, kind: c.status === 'SCHEDULED' ? 'scheduled' : 'sent',
}));
}
// ── live mappers (defensive against v3 field variance) ─────────────────────────
function pct(n, d) { return d > 0 ? Number((n / d).toFixed(4)) : 0; }
function mapCampaign(c) {
const a = c.campaign_activities || c.activities || [];
const stat = c.stats || c.click_through_details || {};
const sends = c.sends || stat.sends || 0;
const opens = c.opens || stat.opens || 0;
const clicks = c.clicks || stat.clicks || 0;
return {
id: c.campaign_id || c.id,
name: c.name || c.campaign_name || '(untitled)',
status: c.current_status || c.status || 'UNKNOWN',
sent_at: c.last_sent_date || c.updated_at || null,
scheduled_at: (a[0] && a[0].scheduled_time) || c.scheduled_time || null,
sends, opens, clicks,
open_rate: pct(opens, sends), click_rate: pct(clicks, sends),
};
}
// ── route table ────────────────────────────────────────────────────────────────
function mount(router) {
const guard = (handler) => async (req, res) => {
try { await handler(req, res); }
catch (e) {
res.status(e.status && e.status < 500 ? e.status : 502)
.json({ error: e.message, detail: e.detail || null });
}
};
// GET /campaigns — list email campaigns + status.
// Priority: local CTCT token → Connie cc-agent proxy → MOCK/CSV fallback.
router.get('/campaigns', guard(async (_req, res) => {
if (live()) {
const data = await cc('/emails?limit=50');
const list = (data.campaigns || data.emails || []).map(mapCampaign);
return res.json({ mock: false, source: 'ctct', campaigns: list });
}
if (connieMode()) {
try {
const data = await connieFetch('api/campaigns');
const raw = Array.isArray(data) ? data : (data.campaigns || data.emails || data.data || []);
const list = raw.map(mapConnieCampaign);
return res.json({ mock: false, source: 'connie', campaigns: list });
} catch (e) {
// cc-agent down/unreachable → graceful MOCK fallback, clearly flagged.
return res.json({ mock: true, source: 'mock', fallback_reason: e.message, campaigns: MOCK.campaigns });
}
}
res.json({ mock: true, source: 'mock', campaigns: MOCK.campaigns });
}));
// GET /contacts — count + recent
router.get('/contacts', guard(async (_req, res) => {
if (!live()) return res.json({ mock: true, ...MOCK.contacts });
const summary = await cc('/contacts?limit=10&include_count=true&sort=created_desc');
const count = (summary.contacts_count != null)
? summary.contacts_count
: (summary._metadata && summary._metadata.total) || (summary.contacts || []).length;
const recent = (summary.contacts || []).slice(0, 6).map(c => ({
email: (c.email_address && c.email_address.address) || c.email || '(no email)',
created_at: c.created_at || null,
}));
res.json({ mock: false, count, recent });
}));
// GET /lists — contact lists
router.get('/lists', guard(async (_req, res) => {
if (!live()) return res.json({ mock: true, lists: MOCK.lists });
const data = await cc('/contact_lists?include_count=true&limit=50');
const lists = (data.lists || []).map(l => ({
id: l.list_id || l.id, name: l.name,
membership_count: l.membership_count != null ? l.membership_count : 0,
}));
res.json({ mock: false, lists });
}));
// GET /calendar — scheduled/sent campaigns as date-keyed entries.
// Priority: local CTCT token → Connie cc-agent proxy → MOCK/CSV fallback.
const toCalendar = (camps) => camps
.filter(c => c.sent_at || c.scheduled_at)
.map(c => ({
date: (c.scheduled_at || c.sent_at).slice(0, 10),
id: c.id, name: c.name, status: c.status,
kind: c.status === 'SCHEDULED' ? 'scheduled' : 'sent',
}));
router.get('/calendar', guard(async (_req, res) => {
if (live()) {
const data = await cc('/emails?limit=50');
const entries = toCalendar((data.campaigns || data.emails || []).map(mapCampaign));
return res.json({ mock: false, source: 'ctct', entries, byDate: groupByDate(entries) });
}
if (connieMode()) {
try {
const data = await connieFetch('api/campaigns');
const raw = Array.isArray(data) ? data : (data.campaigns || data.emails || data.data || []);
const entries = toCalendar(raw.map(mapConnieCampaign));
return res.json({ mock: false, source: 'connie', entries, byDate: groupByDate(entries) });
} catch (e) {
const entries = mockCalendar();
return res.json({ mock: true, source: 'mock', fallback_reason: e.message, entries, byDate: groupByDate(entries) });
}
}
const entries = mockCalendar();
res.json({ mock: true, source: 'mock', entries, byDate: groupByDate(entries) });
}));
// GET /stats — opens/clicks/sends summary
router.get('/stats', guard(async (_req, res) => {
if (!live()) return res.json({ mock: true, stats: MOCK.stats });
const data = await cc('/emails?limit=50');
const camps = (data.campaigns || data.emails || []).map(mapCampaign);
const agg = camps.reduce((a, c) => {
a.sends += c.sends; a.opens += c.opens; a.clicks += c.clicks; return a;
}, { sends: 0, opens: 0, clicks: 0 });
res.json({
mock: false,
stats: {
...agg,
open_rate: pct(agg.opens, agg.sends),
click_rate: pct(agg.clicks, agg.sends),
campaigns_30d: camps.length,
},
});
}));
// POST /draft — create a DRAFT campaign. Does NOT send.
router.post('/draft', guard(async (req, res) => {
const { name, subject, from, html } = req.body || {};
const missing = ['name', 'subject', 'from', 'html'].filter(k => !req.body || !req.body[k]);
if (missing.length) return res.status(400).json({ error: `missing fields: ${missing.join(', ')}` });
const compliance = canSpamCheck(html);
const subjectScan = subjectScreen(subject);
const fromScan = fromScreen(from);
if (!live()) {
const id = `mock-draft-${Date.now().toString(36)}`;
return res.json({
mock: true, ok: true, draft_id: id, status: 'DRAFT',
message: `Draft “${name}” saved (mock). Not sent.`,
compliance, subjectScan, fromScan,
});
}
// v3: create an email campaign with a single primary_email activity.
const payload = {
name,
email_campaign_activities: [{
format_type: 5,
from_name: brand.name,
from_email: from,
reply_to_email: from,
subject,
html_content: html,
}],
};
const created = await cc('/emails', { method: 'POST', body: payload });
res.json({
mock: false, ok: true,
draft_id: created.campaign_id || created.id,
status: created.current_status || 'DRAFT',
message: `Draft “${name}” created. Not sent.`,
compliance, subjectScan, fromScan,
});
}));
// POST /schedule — GATED. Requires confirm:true; defaults to dry-run; refuses
// unless CAN-SPAM fields are present. NEVER sends without confirm && !dryRun.
router.post('/schedule', guard(async (req, res) => {
const b = req.body || {};
const { draft_id, scheduled_time, html, subject, from } = b;
const confirm = b.confirm === true;
const dryRun = b.dryRun !== false; // default true → dry-run unless explicitly false
if (!draft_id) return res.status(400).json({ error: 'draft_id required' });
// CAN-SPAM gate first — block before anything else if non-compliant.
const compliance = canSpamCheck(html || '');
const subjectScan = subjectScreen(subject || '');
const fromScan = fromScreen(from || '');
if (!compliance.ok) {
return res.status(422).json({
ok: false, blocked: true, reason: 'CAN-SPAM',
message: `Refusing to schedule: campaign HTML is missing ${compliance.missing.join(' + ')}. ${brand.compliance}`,
compliance, subjectScan, fromScan,
});
}
// California §17529.5 gate — block on a deceptive subject or bad "From".
// (Only enforced when subject/from are supplied; soft flags are advisory.)
const ca = [];
if (subject != null && !subjectScan.ok) ca.push(...subjectScan.hard);
if (from != null && !fromScan.ok) ca.push(fromScan.reason);
if (ca.length) {
return res.status(422).json({
ok: false, blocked: true, reason: 'CA-17529.5',
message: `Refusing to schedule (CA §17529.5): ${ca.join('; ')}. Fix the subject line / From address and re-submit.`,
compliance, subjectScan, fromScan,
});
}
// Gate: no confirm → never proceeds to a real send path.
if (!confirm) {
return res.status(412).json({
ok: false, gated: true, reason: 'CONFIRM_REQUIRED',
message: 'Schedule is gated. Re-submit with confirm:true (and dryRun:false to actually schedule).',
compliance,
});
}
// Dry-run (default): validate + echo what WOULD happen. No outbound call.
if (dryRun) {
const softWarn = subjectScan.soft.length ? ` Subject advisories: ${subjectScan.soft.join(', ')}.` : '';
return res.json({
ok: true, dryRun: true, wouldSchedule: true,
draft_id, scheduled_time: scheduled_time || 'now',
message: `DRY RUN — would schedule draft ${draft_id} for ${scheduled_time || 'immediate'} send. Nothing was sent. Re-submit with dryRun:false to commit.${softWarn}`,
compliance, subjectScan, fromScan,
});
}
// Live commit: confirm:true + dryRun:false + CAN-SPAM passed.
if (!live()) {
return res.json({
mock: true, ok: true, scheduled: true,
draft_id, scheduled_time: scheduled_time || 'now',
message: `Mock schedule committed for draft ${draft_id}. (No live key — nothing left the building.)`,
compliance,
});
}
// Real v3 schedule. The schedule lives on the campaign activity, so we look
// up the primary activity for the campaign, then POST its schedule.
const detail = await cc(`/emails/${encodeURIComponent(draft_id)}`);
const primary = (detail.campaign_activities || [])
.find(a => a.role === 'primary_email') || (detail.campaign_activities || [])[0];
if (!primary || !primary.campaign_activity_id) {
return res.status(409).json({ ok: false, error: 'No primary email activity found for this draft.' });
}
const schedulePayload = scheduled_time
? { scheduled_date: scheduled_time }
: { scheduled_date: '0' }; // CC convention: "0" == send immediately
const result = await cc(
`/emails/activities/${encodeURIComponent(primary.campaign_activity_id)}/schedules`,
{ method: 'POST', body: schedulePayload },
);
res.json({
mock: false, ok: true, scheduled: true,
draft_id, scheduled_time: scheduled_time || 'immediate',
result, compliance,
message: `Scheduled draft ${draft_id} for ${scheduled_time || 'immediate'} send.`,
});
}));
// ── Phase 1: CSV import (no token) ──────────────────────────────────────────
// POST { kind: 'contacts'|'campaigns'|'opens', csv: '<raw csv text>' }
router.post('/import/csv', guard(async (req, res) => {
const { kind, csv } = req.body || {};
if (!['contacts', 'campaigns', 'opens'].includes(kind)) return res.status(400).json({ error: 'kind must be contacts | campaigns | opens' });
if (!csv || typeof csv !== 'string') return res.status(400).json({ error: 'csv text required' });
const rows = normalizeCsv(kind, rowsToObjects(parseCSV(csv)));
const store = readCtct();
if (kind === 'contacts') { const m = new Map(store.contacts.map(c => [c.email, c])); rows.forEach(c => m.set(c.email, { ...m.get(c.email), ...c })); store.contacts = [...m.values()]; }
else if (kind === 'campaigns') { const m = new Map(store.campaigns.map(c => [c.name, c])); rows.forEach(c => m.set(c.name, c)); store.campaigns = [...m.values()]; }
else { store.opens = store.opens.concat(rows); }
store._meta = { last_import: new Date().toISOString(), contacts: store.contacts.length, campaigns: store.campaigns.length, opens: store.opens.length };
writeCtct(store);
res.json({ ok: true, kind, imported: rows.length, totals: store._meta });
}));
// ── Phase 1: ranked opens (campaigns by open-rate + most-engaged contacts) ──
router.get('/opens-ranked', guard(async (_req, res) => {
const store = readCtct();
const campaigns = store.campaigns.slice()
.sort((a, b) => b.open_rate - a.open_rate || b.clicks - a.clicks)
.map((c, i) => ({ rank: i + 1, ...c }));
const eng = new Map();
for (const o of store.opens) eng.set(o.email, (eng.get(o.email) || 0) + 1);
if (eng.size === 0) for (const c of store.contacts) if (c.opens) eng.set(c.email, c.opens);
const contacts = [...eng.entries()].map(([email, opens]) => ({ email, opens }))
.sort((a, b) => b.opens - a.opens).slice(0, 100).map((c, i) => ({ rank: i + 1, ...c }));
res.json({ source: (store.campaigns.length || store.contacts.length) ? 'csv' : 'empty', campaigns, contacts, meta: store._meta || {} });
}));
}
function groupByDate(entries) {
const out = {};
for (const e of entries) { (out[e.date] = out[e.date] || []).push(e); }
return out;
}
module.exports = {
id: 'constant-contact',
title: 'Constant Contact',
icon: '📣',
mount,
// exported for any internal reuse / testing
_canSpamCheck: canSpamCheck,
_subjectScreen: subjectScreen,
_fromScreen: fromScreen,
};