[object Object]

← 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

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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 →