← back to Designer Wallcoverings
TK-10899: add gated, reversible PJ Fall 2026 mailer asset-swap tool
a6000b9bb24c4c2ecbfa917aab552daefc3fb963 · 2026-09-10 07:36:36 -0700 · Steve
Re-hosts the 5 vendor-CDN hotlinks (4 api.phillipjeffries.com + 1 cdn2.webdamdb.com)
onto the DW Shopify Files CDN - the pattern every other DW mailer already uses. All 5
local copies verified byte-identical SHA-256 to the live vendor objects, so the swap is
a lossless 1:1 substitution.
Dry-run by default (zero writes). --apply is a Shopify write and stays Steve-gated; it
writes a restore map before touching the HTML, and --rollback reverses the src values.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCFwJYZFtrKWTt2wALsWzv
Files touched
A mailers/tools/pj-asset-swap.mjs
Diff
commit a6000b9bb24c4c2ecbfa917aab552daefc3fb963
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 10 07:36:36 2026 -0700
TK-10899: add gated, reversible PJ Fall 2026 mailer asset-swap tool
Re-hosts the 5 vendor-CDN hotlinks (4 api.phillipjeffries.com + 1 cdn2.webdamdb.com)
onto the DW Shopify Files CDN - the pattern every other DW mailer already uses. All 5
local copies verified byte-identical SHA-256 to the live vendor objects, so the swap is
a lossless 1:1 substitution.
Dry-run by default (zero writes). --apply is a Shopify write and stays Steve-gated; it
writes a restore map before touching the HTML, and --rollback reverses the src values.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCFwJYZFtrKWTt2wALsWzv
---
mailers/tools/pj-asset-swap.mjs | 167 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 167 insertions(+)
diff --git a/mailers/tools/pj-asset-swap.mjs b/mailers/tools/pj-asset-swap.mjs
new file mode 100644
index 00000000..5196ac1b
--- /dev/null
+++ b/mailers/tools/pj-asset-swap.mjs
@@ -0,0 +1,167 @@
+#!/usr/bin/env node
+/**
+ * pj-asset-swap.mjs — TK-10899
+ *
+ * Re-hosts the 5 vendor-CDN images in the Phillip Jeffries Fall 2026 mailer onto the
+ * DW Shopify Files CDN (the house pattern every other DW mailer already uses), then
+ * rewrites the mailer's <img src> to the new cdn.shopify.com URLs.
+ *
+ * WHY: hotlinking a vendor CDN in an email is a standing liability — the vendor can
+ * rotate or expire the object at any time and every ALREADY-DELIVERED email breaks
+ * retroactively (there is no way to fix a mail that is already in an inbox). It also
+ * leaks recipient opens/IPs to the vendor. All 5 local copies are byte-identical
+ * SHA-256 to the live vendor objects, so this is a lossless 1:1 substitution.
+ *
+ * SAFETY MODEL:
+ * - DRY-RUN BY DEFAULT. Without --apply it performs ZERO writes: it verifies local
+ * files, re-checks SHA parity against the vendor CDN, and prints the exact swap map.
+ * - --apply is a Shopify write and is STEVE-GATED. Do not run it without approval.
+ * - Every --apply writes a restore map to tools/pj-asset-swap-restore.json BEFORE
+ * touching the HTML, so rollback is mechanical (see --rollback).
+ * - --rollback restores the original vendor-CDN src values from the restore map and
+ * prints the fileDelete GIDs (file deletion stays a deliberate, separate action).
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '..');
+const MAILER = path.join(ROOT, 'phillip-jeffries-fall-2026-launch.html');
+const ASSETS = path.join(ROOT, 'assets', 'pj-fall-2026');
+const RESTORE = path.join(__dirname, 'pj-asset-swap-restore.json');
+
+// vendor CDN url -> byte-identical local copy
+const MAP = [
+ ['https://api.phillipjeffries.com/images/product_lines/WN-SC_395_355x518.jpg', 'pj-coco-weave.jpg'],
+ ['https://api.phillipjeffries.com/images/product_lines/WN-SC_435_355x518.jpg', 'pj-plush-pillars.jpg'],
+ ['https://api.phillipjeffries.com/images/product_lines/WN-SC_454_355x518.jpg', 'pj-cirque-de-soho.jpg'],
+ ['https://api.phillipjeffries.com/images/product_lines/WN-SC_504_355x518.jpg', 'pj-idyllic.jpg'],
+ ['https://cdn2.webdamdb.com/1280_EU2NBNq1Wno66UmS.jpg?1787686038', 'pj-hero-idyllic-room.jpg'],
+];
+
+const args = new Set(process.argv.slice(2));
+const APPLY = args.has('--apply');
+const ROLLBACK = args.has('--rollback');
+const SHOP = process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_FULL_ACCESS_TOKEN; // narrow ADMIN token lacks write_files
+const API = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+
+const sha = b => crypto.createHash('sha256').update(b).digest('hex');
+const log = (...a) => console.log(...a);
+
+async function gql(query, variables = {}) {
+ const r = await fetch(API, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ });
+ const j = await r.json();
+ if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors).slice(0, 400));
+ return j.data;
+}
+
+async function rollback() {
+ if (!fs.existsSync(RESTORE)) return log('No restore map at', RESTORE, '— nothing to roll back.');
+ const rec = JSON.parse(fs.readFileSync(RESTORE, 'utf8'));
+ let html = fs.readFileSync(MAILER, 'utf8');
+ let n = 0;
+ for (const e of rec.swaps) {
+ if (html.includes(e.newUrl)) { html = html.split(e.newUrl).join(e.originalUrl); n++; }
+ }
+ fs.writeFileSync(MAILER, html);
+ log(`Restored ${n}/${rec.swaps.length} src values to the original vendor CDN URLs.`);
+ log('Uploaded Shopify file GIDs (delete deliberately, separately if desired):');
+ rec.swaps.forEach(e => log(' ', e.gid, e.newUrl));
+ log('Also: `git checkout -- mailers/phillip-jeffries-fall-2026-launch.html` fully reverts the HTML.');
+}
+
+async function main() {
+ if (ROLLBACK) return rollback();
+
+ if (!fs.existsSync(MAILER)) throw new Error('Mailer not found: ' + MAILER);
+ let html = fs.readFileSync(MAILER, 'utf8');
+ log(`Mailer: ${MAILER}\n bytes=${html.length} sha=${sha(Buffer.from(html)).slice(0,12)}\n`);
+
+ // --- Preflight: local file exists, is in the HTML, and matches the vendor object ---
+ const plan = [];
+ for (const [url, file] of MAP) {
+ const p = path.join(ASSETS, file);
+ if (!fs.existsSync(p)) throw new Error('Missing local asset: ' + p);
+ const local = fs.readFileSync(p);
+ if (!html.includes(url)) throw new Error('src not present in mailer: ' + url);
+ let parity = 'SKIPPED';
+ try {
+ const r = await fetch(url, { signal: AbortSignal.timeout(30000) });
+ const remote = Buffer.from(await r.arrayBuffer());
+ parity = (sha(remote) === sha(local)) ? 'IDENTICAL' : `MISMATCH(remote ${sha(remote).slice(0,8)})`;
+ } catch { parity = 'UNREACHABLE(using local)'; }
+ plan.push({ url, file, p, bytes: local.length, sha: sha(local).slice(0, 12), parity });
+ log(` ${parity.padEnd(24)} ${file.padEnd(26)} ${local.length} B <- ${url}`);
+ }
+ if (plan.some(x => x.parity.startsWith('MISMATCH')))
+ throw new Error('Aborting: a local copy no longer matches the vendor object. Re-download before swapping.');
+
+ if (!APPLY) {
+ log('\nDRY RUN — no writes performed. 0 network writes, 0 file changes.');
+ log('This is a Shopify write and is STEVE-GATED. To execute after approval:');
+ log(' SHOPIFY_FULL_ACCESS_TOKEN=... node mailers/tools/pj-asset-swap.mjs --apply');
+ log('Rollback after apply: node mailers/tools/pj-asset-swap.mjs --rollback');
+ return;
+ }
+
+ if (!TOKEN) throw new Error('SHOPIFY_FULL_ACCESS_TOKEN not set (the narrow ADMIN token lacks write_files).');
+ const swaps = [];
+
+ for (const item of plan) {
+ log(`\nUploading ${item.file} ...`);
+ // 1) staged target
+ const st = await gql(`mutation($input:[StagedUploadInput!]!){ stagedUploadsCreate(input:$input){
+ stagedTargets{ url resourceUrl parameters{ name value } } userErrors{ field message } } }`,
+ { input: [{ filename: item.file, mimeType: 'image/jpeg', resource: 'FILE', httpMethod: 'POST' }] });
+ if (st.stagedUploadsCreate.userErrors?.length) throw new Error(JSON.stringify(st.stagedUploadsCreate.userErrors));
+ const t = st.stagedUploadsCreate.stagedTargets[0];
+
+ // 2) POST bytes to the staged target
+ const form = new FormData();
+ for (const p of t.parameters) form.append(p.name, p.value);
+ form.append('file', new Blob([fs.readFileSync(item.p)], { type: 'image/jpeg' }), item.file);
+ const up = await fetch(t.url, { method: 'POST', body: form });
+ if (!up.ok) throw new Error(`staged upload failed ${up.status}: ${(await up.text()).slice(0,300)}`);
+
+ // 3) register the file
+ const fc = await gql(`mutation($files:[FileCreateInput!]!){ fileCreate(files:$files){
+ files{ id fileStatus ... on MediaImage { image { url } } } userErrors{ field message } } }`,
+ { files: [{ originalSource: t.resourceUrl, contentType: 'IMAGE', alt: `Phillip Jeffries Fall 2026 — ${item.file}` }] });
+ if (fc.fileCreate.userErrors?.length) throw new Error(JSON.stringify(fc.fileCreate.userErrors));
+ const gid = fc.fileCreate.files[0].id;
+
+ // 4) fileCreate returns UPLOADED, not READY — the CDN url is only populated after
+ // processing. Poll, or we would write an empty src="" into the mailer.
+ let cdn = null;
+ for (let i = 0; i < 30 && !cdn; i++) {
+ await new Promise(r => setTimeout(r, 2000));
+ const q = await gql(`query($id:ID!){ node(id:$id){ ... on MediaImage { fileStatus image { url } } } }`, { id: gid });
+ if (q.node?.fileStatus === 'READY' && q.node.image?.url) cdn = q.node.image.url;
+ if (q.node?.fileStatus === 'FAILED') throw new Error('Shopify reported FAILED for ' + item.file);
+ }
+ if (!cdn) throw new Error('Timed out waiting for READY: ' + item.file);
+ log(` READY -> ${cdn}`);
+ swaps.push({ originalUrl: item.url, file: item.file, gid, newUrl: cdn, sha: item.sha });
+ }
+
+ // restore map BEFORE mutating the HTML
+ fs.writeFileSync(RESTORE, JSON.stringify({ ticket: 'TK-10899', at: new Date().toISOString(), mailer: MAILER, swaps }, null, 2));
+ log('\nRestore map written ->', RESTORE);
+
+ for (const s of swaps) html = html.split(s.originalUrl).join(s.newUrl);
+ fs.writeFileSync(MAILER, html);
+ const left = MAP.filter(([u]) => html.includes(u));
+ log(`Mailer rewritten. Remaining vendor-CDN hotlinks: ${left.length} (expected 0).`);
+ if (left.length) throw new Error('Swap incomplete: ' + left.map(x => x[0]).join(', '));
+ log('\nNOTE: this updates the LOCAL mailer only. Pushing the swapped HTML into the staged');
+ log('Constant Contact draft (54105fd3-...) is a separate, separately-gated step.');
+}
+
+main().catch(e => { console.error('\nFAILED:', e.message); process.exit(1); });
← 43e03dce auto-data-snapshot: 2026-09-09T22:50:09 (2 data files) — scr
·
back to Designer Wallcoverings
·
TK-10899: add in-place PJ Fall 2026 CC draft updater (dry-ru 6197515e →