← 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
A mailers/cc-api/.gitignoreA mailers/cc-api/README.mdA mailers/cc-api/build-china-seas-campaign.jsA mailers/cc-api/cc-client.js
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 →