← back to Quadrille Showroom

scripts/send-sample-request-emails.js

127 lines

#!/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); });