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