← back to Quadrille Showroom
Wire the George email picker for sample requests (sending gated)
fb869a96549f2dc97e8d92f911be523dc411fe90 · 2026-07-02 08:03:37 -0700 · Steve
- scripts/send-sample-request-emails.js: reads data/sample-request-emails.jsonl,
sends each pending row via George (POST /api/send, Basic auth from GEORGE_BASIC_AUTH
or dw-agents Keychain, optional X-Send-Approval), from steve-office. Marks rows
sent/sent_at/message_id; idempotent. --dry-run previews; --health probes George.
- npm run send-sample-emails.
- Safe by default: refuses to send unless SAMPLE_REQUEST_EMAIL_TO is set; NOT scheduled.
Verified: George reachable from Mac2 (status ready), health + dry-run + gated-refusal
all pass without sending; queued row stays sent:false.
Still gated: recipient inbox (SAMPLE_REQUEST_EMAIL_TO) + SAMPLE_REQUEST_EMAIL=on + consent copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M SAMPLE-REQUEST-SPEC.mdM package.jsonA scripts/send-sample-request-emails.js
Diff
commit fb869a96549f2dc97e8d92f911be523dc411fe90
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 2 08:03:37 2026 -0700
Wire the George email picker for sample requests (sending gated)
- scripts/send-sample-request-emails.js: reads data/sample-request-emails.jsonl,
sends each pending row via George (POST /api/send, Basic auth from GEORGE_BASIC_AUTH
or dw-agents Keychain, optional X-Send-Approval), from steve-office. Marks rows
sent/sent_at/message_id; idempotent. --dry-run previews; --health probes George.
- npm run send-sample-emails.
- Safe by default: refuses to send unless SAMPLE_REQUEST_EMAIL_TO is set; NOT scheduled.
Verified: George reachable from Mac2 (status ready), health + dry-run + gated-refusal
all pass without sending; queued row stays sent:false.
Still gated: recipient inbox (SAMPLE_REQUEST_EMAIL_TO) + SAMPLE_REQUEST_EMAIL=on + consent copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
SAMPLE-REQUEST-SPEC.md | 48 +++++++------
package.json | 3 +-
scripts/send-sample-request-emails.js | 126 ++++++++++++++++++++++++++++++++++
3 files changed, 156 insertions(+), 21 deletions(-)
diff --git a/SAMPLE-REQUEST-SPEC.md b/SAMPLE-REQUEST-SPEC.md
index 24c7d99..13fcc2f 100644
--- a/SAMPLE-REQUEST-SPEC.md
+++ b/SAMPLE-REQUEST-SPEC.md
@@ -52,18 +52,23 @@ Append one line to `data/sample-requests.jsonl`. `mkdir -p`-safe, same pattern a
`THUMB_CACHE` (`server.js:162-163`). Record = the validated body + `id` + `created_at`
+ `ip` (already available as `req.ip`). Append-only; never rewrite the file.
-### 2b. Email via George 🔒 (outward-facing — gated)
-On successful persist, fire one email to the DW sales inbox with the request summary
-(samples list, age, contact, notes, timestamp).
-- Send via the **George** Gmail tooling (`mcp__george__gmail_send` / george skill),
- from the **steve-office** account per global rules.
-- **🔒 GATED — needs your go before it sends live:**
- - **Recipient address?** (default guess: the DW sales / info inbox — confirm which.)
- - Enabling an outbound send is an outward-facing action; wire it behind an env flag
- (`SAMPLE_REQUEST_EMAIL=off` by default) so the endpoint ships capturing-to-file
- immediately and the email switches on only when you say go.
-- **Failure isolation:** email failure must NOT fail the request — file persist is the
- source of truth; email is best-effort, logged on error.
+### 2b. Email via George — picker BUILT, sending still gated
+Two halves, both built:
+- **Endpoint hook** (`server.js` `maybeQueueEmail`): on successful persist, if
+ `SAMPLE_REQUEST_EMAIL=on`, append a pending row to `data/sample-request-emails.jsonl`.
+ Default **off** → nothing is queued. Never fails the request (best-effort).
+- **Picker** (`scripts/send-sample-request-emails.js`, `npm run send-sample-emails`):
+ turns queued rows into real mail via George (`POST {GEORGE_URL}/api/send`, Basic auth
+ from `GEORGE_BASIC_AUTH` or the `dw-agents` Keychain, optional `X-Send-Approval`
+ token), from the **steve-office** account. Marks each row `sent` + `sent_at` +
+ `message_id`; idempotent (only touches `sent:false` rows). Verified: George reachable
+ from Mac2, `--health`/`--dry-run`/gated-refusal all pass without sending.
+- **🔒 STILL GATED — to actually send live you set:**
+ - `SAMPLE_REQUEST_EMAIL=on` (env on the server process) so requests get queued.
+ - `SAMPLE_REQUEST_EMAIL_TO=<recipient>` — the DW sales/info inbox. **Which inbox?**
+ Until set, the picker **refuses to send** (prints instructions, sends nothing).
+ - Run `npm run send-sample-emails` (add `--dry-run` to preview). NOT scheduled
+ autonomously — autonomous outbound send stays a deliberate step you turn on.
---
@@ -99,13 +104,16 @@ request as a **card** with:
| Step | Piece | Gated? |
|------|-------|--------|
-| 1 | `POST /api/sample-request` + JSONL persist + validation | no — build now |
-| 2 | Tray form + Request Samples button + confirmation UX | no — build now |
-| 3 | Admin `GET` + card list with date/time chip | no — build now |
-| 4 | Consent-line wording | 🔒 needs your exact copy |
-| 5 | Email via George (env-flag off by default) | 🔒 needs recipient + your go to send live |
-
-Steps 1–3 are fully buildable + verifiable (Playwright/`/5x`) without touching anything
-outward-facing. Steps 4–5 wait on your two answers (consent wording + recipient inbox).
+| 1 | `POST /api/sample-request` + JSONL persist + validation | ✅ built (38c4172) |
+| 2 | Tray form + Request Samples button + confirmation UX | ✅ built (38c4172) |
+| 3 | Admin `GET` + card list with date/time chip | ✅ built (38c4172) |
+| 4 | Consent-line wording | 🔒 placeholder shipped; needs your exact copy |
+| 5 | Email via George — endpoint hook + picker | ✅ built; sending 🔒 on recipient + your go |
+
+Steps 1–3 shipped + verified (curl + Playwright, 0 console errors). Step 5's plumbing
+(queue hook + `send-sample-request-emails.js` picker) is built and reachability-tested;
+it will not send a single email until you set `SAMPLE_REQUEST_EMAIL=on` +
+`SAMPLE_REQUEST_EMAIL_TO=<inbox>`. Step 4 (consent copy) + the recipient inbox are the
+only remaining inputs from you.
**Cost:** $0 (all local). George email, once enabled, is $0 (Gmail API).
diff --git a/package.json b/package.json
index cbb5054..e4dea79 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
"verify-sort-density-persist": "node scripts/verify-sort-density-persist.mjs",
"verify-scene-render": "node scripts/verify-scene-render.mjs",
"verify-table-view": "node scripts/verify-table-view.mjs",
- "verify-all": "npm run verify-sort-density-persist && npm run verify-scene-render && npm run verify-table-view"
+ "verify-all": "npm run verify-sort-density-persist && npm run verify-scene-render && npm run verify-table-view",
+ "send-sample-emails": "node scripts/send-sample-request-emails.js"
},
"license": "ISC",
"description": "The Quadrille House — 3D Endless Wings Wallcovering Showroom (China Seas)",
diff --git a/scripts/send-sample-request-emails.js b/scripts/send-sample-request-emails.js
new file mode 100644
index 0000000..bc45aad
--- /dev/null
+++ b/scripts/send-sample-request-emails.js
@@ -0,0 +1,126 @@
+#!/usr/bin/env node
+/* ============================================================================
+ * send-sample-request-emails.js — the George email PICKER.
+ *
+ * Turns queued rows in data/sample-request-emails.jsonl (written by the
+ * /api/sample-request endpoint when SAMPLE_REQUEST_EMAIL=on) into real mail via
+ * George, the DW Gmail HTTP agent. OUTWARD-FACING + GATED by design:
+ * - Refuses to send unless SAMPLE_REQUEST_EMAIL_TO (recipient) is set.
+ * - `--dry-run` previews what it WOULD send, sends nothing.
+ * - NOT scheduled autonomously — run on demand (npm run send-sample-emails).
+ * Autonomous outbound email stays a deliberate, Steve-gated step.
+ *
+ * George contract mirrors ~/Projects/george-mcp/index.js:
+ * POST {GEORGE_URL}{prefix}/api/send { account, to, subject, html }
+ * headers: Authorization: Basic <GEORGE_BASIC_AUTH|keychain>,
+ * X-Send-Approval: <GEORGE_EXTERNAL_SEND_TOKEN> (only if set)
+ * From-account defaults to steve-office per Steve's global send-from rule.
+ * ==========================================================================*/
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const DATA_DIR = path.join(__dirname, '..', 'data');
+const QUEUE = path.join(DATA_DIR, 'sample-request-emails.jsonl');
+
+const DRY = process.argv.includes('--dry-run');
+const HEALTH = process.argv.includes('--health');
+const GEORGE_URL = (process.env.GEORGE_URL || 'https://kamatera.tail79cb8e.ts.net:9850').replace(/\/$/, '');
+// Direct-port URL → no prefix; bare hostname → Tailscale-Serve "/george" mount (same rule as george-mcp).
+const PATH_PREFIX = process.env.GEORGE_PATH_PREFIX ?? (/:\d{2,5}(\/|$)/.test(GEORGE_URL) ? '' : '/george');
+const ACCOUNT = process.env.SAMPLE_REQUEST_EMAIL_FROM || 'steve-office';
+const TO = process.env.SAMPLE_REQUEST_EMAIL_TO || '';
+const SUBJECT_PREFIX = process.env.SAMPLE_REQUEST_EMAIL_SUBJECT || 'New sample request';
+
+function basicAuth() {
+ if (process.env.GEORGE_BASIC_AUTH) return process.env.GEORGE_BASIC_AUTH;
+ try {
+ const pw = execSync('security find-generic-password -s dw-agents -a admin -w', { encoding: 'utf-8' }).trim();
+ return Buffer.from(`admin:${pw}`).toString('base64');
+ } catch { return null; }
+}
+
+const esc = s => String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
+
+function htmlFor(r) {
+ const items = (r.samples || []).map(s =>
+ `<li>${esc(s.pattern_name || s.sku)}${s.color ? ' — ' + esc(s.color) : ''} <code>${esc(s.sku)}</code></li>`).join('');
+ const c = r.contact || {};
+ const line = (k, v) => v ? `<tr><td style="color:#888;padding:2px 12px 2px 0">${k}</td><td>${esc(v)}</td></tr>` : '';
+ return `<div style="font-family:-apple-system,Segoe UI,sans-serif;color:#222">
+ <h2 style="margin:0 0 4px">New sample request</h2>
+ <p style="color:#888;margin:0 0 14px">${esc(r.created_at)} · ${esc(r.id)}</p>
+ <table style="border-collapse:collapse;margin-bottom:14px">
+ ${line('Name', c.name)}${line('Email', c.email)}${line('Phone', c.phone)}${line('Age', r.age != null ? r.age : '')}
+ </table>
+ <strong>Samples requested (${(r.samples || []).length}):</strong>
+ <ul>${items || '<li>(none)</li>'}</ul>
+ ${r.notes ? `<p><strong>Notes:</strong><br>${esc(r.notes).replace(/\n/g, '<br>')}</p>` : ''}
+ </div>`;
+}
+
+async function georgeFetch(subPath, opts = {}) {
+ const AUTH = basicAuth();
+ if (!AUTH) throw new Error('no George auth (set GEORGE_BASIC_AUTH env or store dw-agents in Keychain)');
+ const headers = { Authorization: `Basic ${AUTH}` };
+ if (opts.body) headers['Content-Type'] = 'application/json';
+ if (process.env.GEORGE_EXTERNAL_SEND_TOKEN) headers['X-Send-Approval'] = process.env.GEORGE_EXTERNAL_SEND_TOKEN;
+ const res = await fetch(GEORGE_URL + PATH_PREFIX + subPath, {
+ method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined,
+ });
+ const text = await res.text();
+ let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
+ if (!res.ok) throw new Error(`George ${res.status}: ${data.error || text.slice(0, 200)}`);
+ return data;
+}
+
+async function sendOne(record) {
+ const req = record.request;
+ const to = TO || record.to;
+ const name = (req.contact && req.contact.name) || req.id;
+ return georgeFetch('/api/send', {
+ method: 'POST',
+ body: { account: ACCOUNT, to, subject: `${SUBJECT_PREFIX} — ${name}`, html: htmlFor(req) },
+ });
+}
+
+(async () => {
+ if (HEALTH) { // connectivity probe only — no auth, no send
+ try { const h = await georgeFetch('/api/health'); console.log('[picker] George reachable:', JSON.stringify(h).slice(0, 200)); }
+ catch (e) { console.error('[picker] George health FAILED:', e.message); process.exit(1); }
+ return;
+ }
+
+ if (!fs.existsSync(QUEUE)) { console.log('[picker] no queue file — nothing to send:', QUEUE); return; }
+ const rows = fs.readFileSync(QUEUE, 'utf8').split('\n').filter(Boolean)
+ .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+ const pending = rows.filter(r => !r.sent);
+ console.log(`[picker] ${rows.length} queued, ${pending.length} pending`);
+ if (!pending.length) return;
+
+ if (!TO) {
+ console.log('[picker] GATED: SAMPLE_REQUEST_EMAIL_TO is not set — refusing to send (nothing sent).');
+ console.log(' Set the recipient and re-run, e.g.:');
+ console.log(' SAMPLE_REQUEST_EMAIL_TO=sales@designerwallcoverings.com npm run send-sample-emails');
+ return;
+ }
+ if (DRY) {
+ console.log(`[picker] DRY RUN — would send ${pending.length} email(s) to ${TO} from ${ACCOUNT}:`);
+ pending.forEach(r => console.log(` - ${r.request.id} (${(r.request.samples || []).length} samples)`));
+ return;
+ }
+
+ let sent = 0, failed = 0;
+ for (const r of pending) {
+ try {
+ const data = await sendOne(r);
+ r.sent = true; r.sent_at = new Date().toISOString(); r.message_id = data.id || data.messageId || null;
+ sent++; console.log(`[picker] sent ${r.request.id} → ${TO} (msg ${r.message_id || '?'}) $0 (Gmail)`);
+ } catch (e) { failed++; r.last_error = e.message; console.error(`[picker] FAILED ${r.request.id}: ${e.message}`); }
+ }
+ // Rewrite the queue atomically with updated per-row status.
+ const tmp = QUEUE + '.tmp';
+ fs.writeFileSync(tmp, rows.map(r => JSON.stringify(r)).join('\n') + '\n');
+ fs.renameSync(tmp, QUEUE);
+ console.log(`[picker] done: ${sent} sent, ${failed} failed. Cost: $0 (Gmail API).`);
+})().catch(e => { console.error('[picker] fatal:', e.message); process.exit(1); });
← 38c4172 Build sample-request submit: endpoint + tray form + admin ca
·
back to Quadrille Showroom
·
chore: lint, refactor, v1.3.0 (session close) 3ac09a0 →