[object Object]

← back to Designer Wallcoverings

Add Constant Contact v3 API scaffold for China Seas mailer (double-gated, dry-run validated)

dbcd31257cb2f402ee9f2eaa3555212946d94e54 · 2026-06-24 12:38:55 -0700 · Steve

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit dbcd31257cb2f402ee9f2eaa3555212946d94e54
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 24 12:38:55 2026 -0700

    Add Constant Contact v3 API scaffold for China Seas mailer (double-gated, dry-run validated)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 mailers/cc-api/.gitignore                   |   6 +
 mailers/cc-api/README.md                    | 113 ++++++++++++
 mailers/cc-api/build-china-seas-campaign.js | 231 ++++++++++++++++++++++++
 mailers/cc-api/cc-client.js                 | 270 ++++++++++++++++++++++++++++
 4 files changed, 620 insertions(+)

diff --git a/mailers/cc-api/.gitignore b/mailers/cc-api/.gitignore
new file mode 100644
index 00000000..8ff491f0
--- /dev/null
+++ b/mailers/cc-api/.gitignore
@@ -0,0 +1,6 @@
+# Constant Contact OAuth access-token cache (contains a live bearer token)
+.cc-token-cache.json
+
+# never commit env
+.env
+.env.*
diff --git a/mailers/cc-api/README.md b/mailers/cc-api/README.md
new file mode 100644
index 00000000..a774294b
--- /dev/null
+++ b/mailers/cc-api/README.md
@@ -0,0 +1,113 @@
+# China Seas → Constant Contact v3 integration
+
+A clean, double-gated Constant Contact **v3 REST API** client for pushing the
+China Seas launch mailer into Constant Contact as a custom-code campaign.
+
+**Nothing here calls the live API by default.** Outward writes (campaign create,
+schedule) require BOTH `CC_LIVE=1` in the env AND an explicit `--confirm` flag.
+The actual send stays gated on Steve's go + compliance sign-off.
+
+Files:
+- `cc-client.js` — OAuth2 refresh + `listContactLists` / `createCustomCodeCampaign` / `scheduleCampaign`
+- `build-china-seas-campaign.js` — loads `../china-seas-launch.html`, builds the exact payload, validates
+- `.cc-token-cache.json` — runtime access-token cache (gitignored)
+
+---
+
+## (a) Create a v3 app → get CLIENT_ID / CLIENT_SECRET
+
+1. Go to <https://developer.constantcontact.com/> and sign in with the Constant
+   Contact account that owns the DW contact lists.
+2. **My Applications → New Application.**
+3. Set the **OAuth2 redirect URI** to a value you control, e.g.
+   `https://designerwallcoverings.com/cc-oauth/callback` (or `http://localhost:8080/callback`
+   for a one-time local mint). **Steve must supply / approve the redirect URI — do not invent one.**
+4. Note the generated **API Key** (`CC_CLIENT_ID`) and **App Secret**
+   (`CC_CLIENT_SECRET`). The secret is shown once — copy it immediately.
+
+## (b) Mint the refresh token (Auth-Code + PKCE flow)
+
+Constant Contact v3 uses OAuth2 authorization-code. To get a long-lived refresh
+token you request the `offline_access` scope.
+
+1. **Authorize URL** — open this in a browser (replace `CLIENT_ID` and the
+   redirect to match your app; `state` is any random string):
+
+   ```
+   https://authz.constantcontact.com/oauth2/default/v1/authorize
+     ?client_id=CLIENT_ID
+     &redirect_uri=REDIRECT_URI
+     &response_type=code
+     &scope=campaign_data+offline_access
+     &state=dw-china-seas
+   ```
+
+   (PKCE: optionally add `&code_challenge=...&code_challenge_method=S256` and
+   keep the matching `code_verifier` for the token exchange.)
+
+2. Approve. The browser redirects to `REDIRECT_URI?code=AUTH_CODE&state=...`.
+   Copy `AUTH_CODE`.
+
+3. **Exchange the code for tokens** (token endpoint
+   `https://authz.constantcontact.com/oauth2/default/v1/token`):
+
+   ```sh
+   BASIC=$(printf '%s' "CLIENT_ID:CLIENT_SECRET" | base64)
+   curl -s -X POST https://authz.constantcontact.com/oauth2/default/v1/token \
+     -H "Authorization: Basic $BASIC" \
+     -H "Content-Type: application/x-www-form-urlencoded" \
+     -d "grant_type=authorization_code" \
+     -d "code=AUTH_CODE" \
+     -d "redirect_uri=REDIRECT_URI"
+     # if you used PKCE, also: -d "code_verifier=YOUR_VERIFIER"
+   ```
+
+   The response contains `access_token` (≈2 h) and a `refresh_token`. Keep the
+   **`refresh_token`** — that is `CC_REFRESH_TOKEN`. `cc-client.js` uses it to
+   mint fresh access tokens automatically (refresh-token grant) and caches the
+   access token in `.cc-token-cache.json`.
+
+## (c) Where the creds go — route via the `secrets` skill
+
+Do **not** hardcode. Hand the three values to Steve / paste them and let the
+`secrets` skill fan them out (master `.env`, this project, MCP env, etc.):
+
+```
+CC_CLIENT_ID=<api key>
+CC_CLIENT_SECRET=<app secret>
+CC_REFRESH_TOKEN=<refresh token from step b>
+```
+
+The secrets skill will add a `routes.json` entry if one doesn't exist yet.
+
+## (d) Run it — dry-run, then the gated confirm
+
+```sh
+cd mailers/cc-api
+
+# 1. DRY-RUN (default) — prints payload + validation checklist, NO network.
+node build-china-seas-campaign.js --dry-run
+# (today this exits non-zero: the 6 vendor logos aren't hosted at https yet —
+#  that FAIL is expected and correct, not a bug.)
+
+# 2. Once creds are routed + logos hosted + address confirmed, list lists:
+CC_LIVE=1 node -e "require('./cc-client').listContactLists().then(r=>console.log(JSON.stringify(r,null,2)))"
+
+# 3. GATED create (double-gated: needs CC_LIVE=1 AND --confirm; refuses if
+#    validation still has FAILs). Without CC_LIVE=1 it NO-OPs and prints intent.
+node build-china-seas-campaign.js --confirm            # NO-OP (env gate closed)
+CC_LIVE=1 node build-china-seas-campaign.js --confirm  # real create (Steve-gated)
+```
+
+`scheduleCampaign(activityId, scheduledDate)` is the same shape — `CC_LIVE=1` +
+`confirm:true` required; `scheduledDate` is ISO-8601 (or `"0"` for immediate per CC v3).
+
+---
+
+## Hard gates (do not cross without Steve)
+
+- No live campaign create / schedule / send without Steve's explicit go.
+- Sending to a list is an outbound comm → **`vp-compliance-policy` sign-off
+  required** (CAN-SPAM / §17529.5 / TCPA / CCPA) before any send.
+- See `~/.claude/yolo-queue/pending-approval/china-seas-cc-api-readiness.md`
+  for the full blocker checklist.
diff --git a/mailers/cc-api/build-china-seas-campaign.js b/mailers/cc-api/build-china-seas-campaign.js
new file mode 100644
index 00000000..e70825c7
--- /dev/null
+++ b/mailers/cc-api/build-china-seas-campaign.js
@@ -0,0 +1,231 @@
+'use strict';
+
+/**
+ * build-china-seas-campaign.js — Designer Wallcoverings
+ *
+ * Loads ../china-seas-launch.html, constructs the exact Constant Contact v3
+ * create-campaign payload, and either:
+ *   --dry-run  (DEFAULT): prints the full payload + runs a validation checklist.
+ *              NEVER hits the network.
+ *   --confirm           : would create the campaign — STILL double-gated by
+ *              CC_LIVE === '1' inside cc-client. Without CC_LIVE it NO-OPs.
+ *
+ * The validation checklist is EXPECTED to FAIL today on:
+ *   - "all image src absolute https"  (the 6 vendor logos point at a path that
+ *     isn't a confirmed-live https asset yet — they must be hosted first)
+ *   - possibly "physical address non-placeholder"
+ * Those FAILs are correct — they are the remaining prerequisites, not bugs.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const cc = require('./cc-client');
+
+const MAILER = path.join(__dirname, '..', 'china-seas-launch.html');
+
+const argv = process.argv.slice(2);
+const CONFIRM = argv.includes('--confirm');
+const DRY_RUN = argv.includes('--dry-run') || !CONFIRM; // default dry-run
+
+// --- Campaign definition (edit creds/address at send-prep, not the HTML) ----
+const CAMPAIGN = {
+  name: `China Seas Launch — ${new Date().toISOString().slice(0, 10)}`,
+  subject: 'China Seas — The Quadrille House, now at Designer Wallcoverings',
+  preheader: 'Hand-printed lattice, ikat & chinoiserie — wallcovering and matching fabric.',
+  fromName: 'Designer Wallcoverings',
+  fromEmail: 'steve@designerwallcoverings.com', // must be a CC-verified from address
+  replyTo: 'steve@designerwallcoverings.com',
+  // CAN-SPAM physical address — confirm/replace at send-prep. Flagged as
+  // placeholder-suspect if it still matches the mailer's commented placeholder.
+  physicalAddress: {
+    address_line1: '15442 Ventura Boulevard #102',
+    city: 'Sherman Oaks',
+    state_code: 'CA',
+    postal_code: '91403',
+    country_code: 'US',
+  },
+};
+
+// Heuristic: this exact string is the placeholder baked into the mailer comment.
+const PLACEHOLDER_ADDRESS_MARKER = '15442 Ventura Boulevard #102';
+
+function loadHtml() {
+  if (!fs.existsSync(MAILER)) {
+    throw new Error(`mailer not found: ${MAILER}`);
+  }
+  return fs.readFileSync(MAILER, 'utf8');
+}
+
+// Extract every <img src="..."> for the absolute-https check.
+function extractImageSrcs(html) {
+  const srcs = [];
+  const re = /<img\b[^>]*?\bsrc\s*=\s*(["'])(.*?)\1/gi;
+  let m;
+  while ((m = re.exec(html)) !== null) srcs.push(m[2]);
+  return srcs;
+}
+
+function isAbsoluteHttps(url) {
+  return /^https:\/\//i.test(url);
+}
+
+// Vendor logos that aren't hosted at a confirmed-live public https URL yet.
+// Per the mailer's own comment, these point at a DW path that returns 301
+// (not a confirmed asset). Flag any logo src on that un-hosted path.
+function unhostedLogoSrcs(srcs) {
+  return srcs.filter((s) => /\/vendor-logos\//i.test(s));
+}
+
+// Customer-visible "Wallpaper" surfaces only: alt text + rendered text between
+// tags. We deliberately DON'T flag the word inside third-party S3 image URLs
+// (e.g. Quadrille's `Wallpaper_Images/` bucket path — not ours to rename), an
+// existing live collection slug (`/collections/schumacher-wallpaper`), or HTML
+// comments — the DW brand rule bans the word in COPY/TITLES/CAPTIONS/ALT, not
+// in upstream URLs we don't control.
+function visibleWallpaperHits(html) {
+  const hits = [];
+  // strip HTML comments first
+  const noComments = html.replace(/<!--[\s\S]*?-->/g, '');
+  // alt attributes
+  const altRe = /\balt\s*=\s*(["'])(.*?)\1/gi;
+  let m;
+  while ((m = altRe.exec(noComments)) !== null) {
+    if (/\bwallpapers?\b/i.test(m[2])) hits.push(`alt: "${m[2]}"`);
+  }
+  // visible text nodes (between > and <), with tags removed
+  const textOnly = noComments.replace(/<[^>]+>/g, ' ');
+  const textHits = textOnly.match(/\bwallpapers?\b/gi) || [];
+  for (const t of textHits) hits.push(`visible text: "${t}"`);
+  return hits;
+}
+
+function runChecklist(html) {
+  const results = [];
+  const add = (name, pass, detail) => results.push({ name, pass, detail });
+
+  add('subject set', !!CAMPAIGN.subject && CAMPAIGN.subject.trim().length > 0, CAMPAIGN.subject);
+  add('from name + email set',
+    !!CAMPAIGN.fromName && !!CAMPAIGN.fromEmail && CAMPAIGN.fromEmail.includes('@'),
+    `${CAMPAIGN.fromName} <${CAMPAIGN.fromEmail}>`);
+  add('reply-to set',
+    !!CAMPAIGN.replyTo && CAMPAIGN.replyTo.includes('@'),
+    CAMPAIGN.replyTo);
+
+  // physical address present + non-placeholder
+  const addr = CAMPAIGN.physicalAddress || {};
+  const addrComplete = !!(addr.address_line1 && addr.city && addr.state_code && addr.postal_code && addr.country_code);
+  const addrIsPlaceholder = (addr.address_line1 || '').includes(PLACEHOLDER_ADDRESS_MARKER);
+  add('physical address complete', addrComplete, JSON.stringify(addr));
+  add('physical address non-placeholder', addrComplete && !addrIsPlaceholder,
+    addrIsPlaceholder
+      ? 'matches the mailer placeholder marker — confirm the real DW postal address at send-prep'
+      : 'confirmed non-placeholder');
+
+  // html non-empty
+  add('html content non-empty', !!html && html.length > 200, `${html.length} bytes`);
+
+  // banned word "Wallpaper" must not appear in CUSTOMER-VISIBLE copy/alt
+  // (DW brand rule; this is the DW main mailer). Upstream URL paths / live
+  // collection slugs / HTML comments are intentionally NOT flagged.
+  const bannedHits = visibleWallpaperHits(html);
+  add('no banned word "Wallpaper" (visible copy + alt)', bannedHits.length === 0,
+    bannedHits.length ? `found in visible surfaces:\n      - ${bannedHits.join('\n      - ')}` : 'clean (alt + visible text)');
+
+  // all image src absolute https
+  const srcs = extractImageSrcs(html);
+  const nonHttps = srcs.filter((s) => !isAbsoluteHttps(s));
+  add('all image src absolute https', nonHttps.length === 0,
+    nonHttps.length ? `${nonHttps.length} of ${srcs.length} not absolute-https:\n      - ${nonHttps.join('\n      - ')}`
+                    : `${srcs.length}/${srcs.length} absolute-https`);
+
+  // logos hosted at a confirmed-live public URL (the real send-prep blocker)
+  const unhosted = unhostedLogoSrcs(srcs);
+  add('vendor logos hosted at public https (not the un-hosted /vendor-logos/ path)',
+    unhosted.length === 0,
+    unhosted.length
+      ? `${unhosted.length} logo(s) still on the un-hosted DW /vendor-logos/ path (returns 301; host on Shopify Files CDN first):\n      - ${unhosted.join('\n      - ')}`
+      : 'all logos on a confirmed-live host');
+
+  return results;
+}
+
+function buildPayload(html) {
+  return {
+    name: CAMPAIGN.name,
+    email_campaign_activities: [
+      {
+        format_type: 5,
+        from_name: CAMPAIGN.fromName,
+        from_email: CAMPAIGN.fromEmail,
+        reply_to_email: CAMPAIGN.replyTo,
+        subject: CAMPAIGN.subject,
+        preheader: CAMPAIGN.preheader,
+        html_content: html,
+        physical_address_in_footer: CAMPAIGN.physicalAddress,
+      },
+    ],
+  };
+}
+
+async function main() {
+  const html = loadHtml();
+
+  console.log('='.repeat(72));
+  console.log('China Seas → Constant Contact v3 campaign builder');
+  console.log(`mode: ${CONFIRM ? '--confirm' : '--dry-run (default)'}   CC_LIVE=${process.env.CC_LIVE === '1' ? '1' : '(unset)'}`);
+  console.log('='.repeat(72));
+
+  // --- payload (always printed in dry-run; html truncated for readability) ---
+  const payload = buildPayload(html);
+  const printable = JSON.parse(JSON.stringify(payload));
+  printable.email_campaign_activities[0].html_content =
+    `<<${html.length} bytes of HTML — printed truncated>>\n` + html.slice(0, 400) + '\n…';
+  console.log('\n--- CREATE-CAMPAIGN PAYLOAD (html_content truncated) ---');
+  console.log(JSON.stringify(printable, null, 2));
+
+  // --- validation checklist ---
+  console.log('\n--- VALIDATION CHECKLIST ---');
+  const results = runChecklist(html);
+  let failed = 0;
+  for (const r of results) {
+    const mark = r.pass ? 'PASS' : 'FAIL';
+    if (!r.pass) failed++;
+    console.log(`  [${mark}] ${r.name}`);
+    if (r.detail) console.log(`         ${r.detail}`);
+  }
+  console.log(`\n  ${results.length - failed}/${results.length} checks passed, ${failed} failed.`);
+
+  if (DRY_RUN) {
+    console.log('\nDRY-RUN: no network call made. (default)');
+    if (failed > 0) {
+      console.log('Validation has FAILs — these are the remaining send-prep prerequisites. See README + readiness memo.');
+    }
+    process.exitCode = failed > 0 ? 2 : 0;
+    return;
+  }
+
+  // --- --confirm path: still double-gated inside cc-client by CC_LIVE ---
+  if (failed > 0) {
+    console.log('\nREFUSING to create campaign: validation has FAILs. Fix the prerequisites first.');
+    process.exitCode = 2;
+    return;
+  }
+  console.log('\n--confirm passed + validation clean → attempting create (NO-OPs unless CC_LIVE=1):');
+  const activityId = await cc.createCustomCodeCampaign({
+    name: CAMPAIGN.name,
+    subject: CAMPAIGN.subject,
+    preheader: CAMPAIGN.preheader,
+    fromEmail: CAMPAIGN.fromEmail,
+    fromName: CAMPAIGN.fromName,
+    replyTo: CAMPAIGN.replyTo,
+    htmlContent: html,
+    physicalAddress: CAMPAIGN.physicalAddress,
+    confirm: true,
+  });
+  console.log('result:', activityId);
+}
+
+main().catch((e) => {
+  console.error('[build-china-seas] ERROR:', e.message);
+  process.exitCode = 1;
+});
diff --git a/mailers/cc-api/cc-client.js b/mailers/cc-api/cc-client.js
new file mode 100644
index 00000000..7f2f58f8
--- /dev/null
+++ b/mailers/cc-api/cc-client.js
@@ -0,0 +1,270 @@
+'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');
+
+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 = process.env.CC_LIVE === '1';
+
+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;
+}
+
+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');
+  const body = new URLSearchParams({
+    refresh_token: CC_REFRESH_TOKEN,
+    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);
+  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,
+  });
+}
+
+module.exports = {
+  getAccessToken,
+  listContactLists,
+  createCustomCodeCampaign,
+  scheduleCampaign,
+  // exposed for tests/build script
+  _internal: { isLive, noopReason, TOKEN_URL, API_BASE, TOKEN_CACHE },
+};

← 09b3f565 Add China Seas launch email mailer (6 theme pills + 6 herita  ·  back to Designer Wallcoverings  ·  Host China Seas mailer vendor logos on Shopify CDN; swap 6 i ec18eb1c →