← 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,
};