← 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 => ({ '&': '&', '<': '<', '>': '>' }[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); });