← back to Designer Wallcoverings

mailers/cc-api/cc-client.js

345 lines

'use strict';

/**
 * cc-client.js — Designer Wallcoverings
 * Clean Constant Contact v3 REST API client (Node 18+, built-in fetch only).
 *
 * SAFETY MODEL (read this before touching the network):
 *   - All OUTWARD WRITES (campaign create, schedule) are double-gated:
 *       (1) process.env.CC_LIVE === '1'   AND
 *       (2) an explicit `confirm: true` option / `--confirm` CLI flag.
 *     Absent EITHER gate, the call NO-OPs: it prints the intended request
 *     and returns a mock response. There is no code path that silently
 *     hits the live API.
 *   - Read-only calls (listContactLists) hit the network only when CC_LIVE === '1';
 *     otherwise they also NO-OP with a mock so the scaffold runs with zero creds.
 *
 * CREDS (route via the `secrets` skill, never hardcode):
 *   CC_CLIENT_ID, CC_CLIENT_SECRET, CC_REFRESH_TOKEN  (see ./README.md)
 *
 * v3 endpoints used:
 *   token   : https://authz.constantcontact.com/oauth2/default/v1/token
 *   lists   : GET  https://api.cc.email/v3/contact_lists
 *   create  : POST https://api.cc.email/v3/emails
 *   schedule: POST https://api.cc.email/v3/emails/activities/{id}/schedules
 */

const fs = require('fs');
const path = require('path');
// Arming must come from the invoking process, never from the credential file.
// Capture it before dotenv loads so a persisted CC_LIVE=1 cannot silently turn
// a local `--confirm` review into a live API request.
const LIVE_ARMED_BY_CALLER = process.env.CC_LIVE === '1';
// Auto-load local gitignored .env (CC_CLIENT_ID/SECRET/REFRESH_TOKEN) so runs
// don't need the creds exported by hand. CC_LIVE is intentionally excluded
// from this convenience: it must be explicit for every live invocation.
try { require('dotenv').config({ path: path.join(__dirname, '.env') }); } catch { /* dotenv optional */ }
if (!LIVE_ARMED_BY_CALLER) delete process.env.CC_LIVE;

const TOKEN_URL = 'https://authz.constantcontact.com/oauth2/default/v1/token';
const API_BASE = 'https://api.cc.email/v3';
const TOKEN_CACHE = path.join(__dirname, '.cc-token-cache.json'); // gitignored

const LIVE = LIVE_ARMED_BY_CALLER;

function log(...a) { console.log('[cc-client]', ...a); }

function isLive(confirm) {
  return LIVE && confirm === true;
}

/**
 * Reason a network call would be a NO-OP, or null if it would proceed live.
 * @param {boolean} confirm
 * @param {boolean} writeOp  true for outward writes (needs both gates)
 */
function noopReason(confirm, writeOp) {
  if (writeOp) {
    if (!LIVE && confirm !== true) return 'CC_LIVE!=1 AND --confirm not passed';
    if (!LIVE) return 'CC_LIVE!=1 (env gate closed)';
    if (confirm !== true) return '--confirm not passed (explicit gate closed)';
    return null;
  }
  // read op: env gate only
  return LIVE ? null : 'CC_LIVE!=1 (env gate closed)';
}

// ---------------------------------------------------------------------------
// OAuth2 — refresh-token grant
// ---------------------------------------------------------------------------

function readTokenCache() {
  try {
    const raw = fs.readFileSync(TOKEN_CACHE, 'utf8');
    const j = JSON.parse(raw);
    if (j && j.access_token && j.expires_at && Date.now() < j.expires_at - 60_000) {
      return j;
    }
  } catch (_) { /* no/invalid cache */ }
  return null;
}

// CC rotates the refresh token on every use — the freshest one lives in the
// cache file, not .env. An expired cache still holds the ONLY valid refresh token.
function readCachedRefreshToken() {
  try {
    const j = JSON.parse(fs.readFileSync(TOKEN_CACHE, 'utf8'));
    return (j && j.refresh_token) || null;
  } catch (_) { return null; }
}

// Persist the rotated refresh token back into .env so a wiped cache can't
// strand us with a dead token again.
function persistRefreshTokenToEnv(refreshToken) {
  if (!refreshToken) return;
  const envPath = path.join(__dirname, '.env');
  try {
    let env = fs.readFileSync(envPath, 'utf8');
    if (/^CC_REFRESH_TOKEN=.*$/m.test(env)) {
      env = env.replace(/^CC_REFRESH_TOKEN=.*$/m, `CC_REFRESH_TOKEN=${refreshToken}`);
    } else {
      env += `\nCC_REFRESH_TOKEN=${refreshToken}\n`;
    }
    fs.writeFileSync(envPath, env, { mode: 0o600 });
  } catch (e) {
    log('WARN: could not persist rotated refresh token to .env:', e.message);
  }
}

function writeTokenCache(tok) {
  const expires_at = Date.now() + (Number(tok.expires_in || 0) * 1000);
  const payload = { access_token: tok.access_token, refresh_token: tok.refresh_token, expires_at };
  try {
    fs.writeFileSync(TOKEN_CACHE, JSON.stringify(payload, null, 2), { mode: 0o600 });
  } catch (e) {
    log('WARN: could not write token cache:', e.message);
  }
  return payload;
}

/**
 * Get a valid access token, refreshing via CC_REFRESH_TOKEN when needed.
 * NO-OPs (returns a mock token) unless CC_LIVE === '1'.
 * @returns {Promise<string>} access token (mock string when not live)
 */
async function getAccessToken() {
  if (!LIVE) {
    log('NO-OP getAccessToken: CC_LIVE!=1 → returning mock token');
    return 'MOCK_ACCESS_TOKEN';
  }

  const cached = readTokenCache();
  if (cached) {
    log('using cached access token (valid until', new Date(cached.expires_at).toISOString() + ')');
    return cached.access_token;
  }

  const { CC_CLIENT_ID, CC_CLIENT_SECRET, CC_REFRESH_TOKEN } = process.env;
  if (!CC_CLIENT_ID || !CC_CLIENT_SECRET || !CC_REFRESH_TOKEN) {
    throw new Error(
      'Missing CC_CLIENT_ID / CC_CLIENT_SECRET / CC_REFRESH_TOKEN. ' +
      'Route them via the `secrets` skill — see ./README.md.'
    );
  }

  const basic = Buffer.from(`${CC_CLIENT_ID}:${CC_CLIENT_SECRET}`).toString('base64');
  // Prefer the cache's rotated refresh token (CC single-use rotation) over the
  // possibly-stale .env copy.
  const refreshToken = readCachedRefreshToken() || CC_REFRESH_TOKEN;
  const body = new URLSearchParams({
    refresh_token: refreshToken,
    grant_type: 'refresh_token',
  });

  log('POST', TOKEN_URL, '(refresh_token grant)');
  const res = await fetch(TOKEN_URL, {
    method: 'POST',
    headers: {
      'Authorization': `Basic ${basic}`,
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body,
  });
  if (!res.ok) {
    const txt = await res.text().catch(() => '');
    throw new Error(`token refresh failed: ${res.status} ${res.statusText} ${txt}`);
  }
  const tok = await res.json();
  const saved = writeTokenCache(tok);
  persistRefreshTokenToEnv(tok.refresh_token);
  log('refreshed access token (expires', new Date(saved.expires_at).toISOString() + ')');
  return saved.access_token;
}

// ---------------------------------------------------------------------------
// Generic authed request helper
// ---------------------------------------------------------------------------

async function apiFetch(method, urlPath, { body, confirm = false, writeOp = false } = {}) {
  const url = urlPath.startsWith('http') ? urlPath : `${API_BASE}${urlPath}`;
  const reason = noopReason(confirm, writeOp);

  if (reason) {
    log(`NO-OP ${method} ${url} — ${reason}`);
    if (body !== undefined) log('  intended body:', JSON.stringify(body));
    return { __mock: true, reason, request: { method, url, body } };
  }

  const token = await getAccessToken();
  log(`LIVE ${method} ${url}`);
  const res = await fetch(url, {
    method,
    headers: {
      'Authorization': `Bearer ${token}`,
      'Accept': 'application/json',
      ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
    },
    ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
  });
  const txt = await res.text();
  let json;
  try { json = txt ? JSON.parse(txt) : {}; } catch (_) { json = { raw: txt }; }
  if (!res.ok) {
    throw new Error(`${method} ${url} → ${res.status} ${res.statusText}: ${txt}`);
  }
  return json;
}

// ---------------------------------------------------------------------------
// Read: contact lists
// ---------------------------------------------------------------------------

/**
 * GET /v3/contact_lists — NO-OPs (mock) unless CC_LIVE === '1'.
 */
async function listContactLists() {
  return apiFetch('GET', '/contact_lists', { writeOp: false });
}

// ---------------------------------------------------------------------------
// Write: create a custom-code (format_type 5) campaign
// ---------------------------------------------------------------------------

/**
 * POST /v3/emails — create a custom-code HTML campaign.
 * GATED OUTWARD WRITE: requires CC_LIVE === '1' AND confirm === true.
 *
 * @param {object} p
 * @param {string} p.name             campaign name (internal)
 * @param {string} p.subject          subject line
 * @param {string} p.fromEmail        verified from address
 * @param {string} p.fromName         from display name
 * @param {string} p.replyTo          reply-to address
 * @param {string} p.htmlContent      full custom HTML
 * @param {object} p.physicalAddress  CAN-SPAM footer address
 * @param {string} [p.preheader]      optional preheader text
 * @param {boolean} [p.confirm]       explicit confirm gate
 * @returns {Promise<string|object>}  campaign_activity_id (live) or mock object
 */
async function createCustomCodeCampaign(p) {
  const {
    name, subject, fromEmail, fromName, replyTo, htmlContent,
    physicalAddress, preheader, confirm = false,
  } = p || {};

  if (!isLive(confirm)) {
    const reason = noopReason(confirm, true);
    log(`REFUSING live campaign create — ${reason}.`);
    log('  To actually create, run with CC_LIVE=1 AND pass --confirm / { confirm: true }.');
  }

  const payload = {
    name,
    email_campaign_activities: [
      {
        format_type: 5, // 5 = custom code HTML
        from_name: fromName,
        from_email: fromEmail,
        reply_to_email: replyTo,
        subject,
        ...(preheader ? { preheader } : {}),
        html_content: htmlContent,
        physical_address_in_footer: physicalAddress,
      },
    ],
  };

  const resp = await apiFetch('POST', '/emails', { body: payload, confirm, writeOp: true });
  if (resp && resp.__mock) return resp;

  // Live response: pull the campaign_activity_id of the primary (resend/email) activity.
  const acts = resp.campaign_activities || [];
  const primary =
    acts.find((a) => a.role === 'primary_email') || acts[0] || {};
  const activityId = primary.campaign_activity_id || resp.campaign_activity_id;
  log('created campaign_activity_id:', activityId, '(campaign_id', resp.campaign_id + ')');
  return activityId;
}

// ---------------------------------------------------------------------------
// Write: schedule a campaign activity
// ---------------------------------------------------------------------------

/**
 * POST /v3/emails/activities/{id}/schedules — schedule a send.
 * GATED OUTWARD WRITE: requires CC_LIVE === '1' AND confirm === true.
 *
 * @param {string} activityId      campaign_activity_id from createCustomCodeCampaign
 * @param {string} scheduledDate   ISO-8601, or "0" for immediate (per CC v3)
 * @param {boolean} [confirm]
 */
async function scheduleCampaign(activityId, scheduledDate, confirm = false) {
  if (!activityId) throw new Error('scheduleCampaign: activityId required');
  if (!isLive(confirm)) {
    const reason = noopReason(confirm, true);
    log(`REFUSING live schedule — ${reason}.`);
  }
  const payload = { scheduled_date: scheduledDate };
  return apiFetch('POST', `/emails/activities/${activityId}/schedules`, {
    body: payload, confirm, writeOp: true,
  });
}

/**
 * PUT /v3/emails/activities/{id} — update an existing campaign activity's content.
 * GATED OUTWARD WRITE: requires CC_LIVE === '1' AND confirm === true.
 */
async function updateCampaignActivity(activityId, p, confirm = false) {
  if (!activityId) throw new Error('updateCampaignActivity: activityId required');
  const {
    subject, fromEmail, fromName, replyTo, htmlContent, physicalAddress, preheader,
  } = p || {};
  if (!isLive(confirm)) {
    const reason = noopReason(confirm, true);
    log(`REFUSING live activity update — ${reason}.`);
  }
  const payload = {
    format_type: 5,
    from_name: fromName,
    from_email: fromEmail,
    reply_to_email: replyTo,
    subject,
    ...(preheader ? { preheader } : {}),
    html_content: htmlContent,
    physical_address_in_footer: physicalAddress,
  };
  return apiFetch('PUT', `/emails/activities/${activityId}`, { body: payload, confirm, writeOp: true });
}

/** GET /v3/emails/activities/{id} — read a campaign activity back (read-only). */
async function getCampaignActivity(activityId) {
  if (!activityId) throw new Error('getCampaignActivity: activityId required');
  return apiFetch('GET', `/emails/activities/${activityId}`, { writeOp: false });
}

module.exports = {
  getAccessToken,
  listContactLists,
  createCustomCodeCampaign,
  updateCampaignActivity,
  getCampaignActivity,
  scheduleCampaign,
  // exposed for tests/build script
  _internal: { isLive, noopReason, TOKEN_URL, API_BASE, TOKEN_CACHE },
};