← back to Claude Email Responder
server.js
267 lines
// Claude Email Responder — Steve emails one of his George accounts, Claude
// replies in-thread. Strict whitelist, single-reply-per-message idempotency.
require('dotenv').config();
const express = require('express');
const fs = require('node:fs');
const path = require('node:path');
const { spawn } = require('node:child_process');
const cfg = require('./config');
const georgeAuthB64 = Buffer.from(cfg.george.basicAuth).toString('base64');
const CLAUDE_BIN = process.env.CLAUDE_BIN || '/Users/macstudio3/.local/bin/claude';
const CLAUDE_TIMEOUT_MS = parseInt(process.env.CLAUDE_TIMEOUT_MS || '90000', 10);
// ─── State (replied msgIds + last-seen-internalDate cursor per account) ─────
function loadState() {
try { return JSON.parse(fs.readFileSync(cfg.stateFile, 'utf8')); }
catch { return { replied: {}, cursor: {}, lastError: null }; }
}
function saveState(s) {
fs.mkdirSync(path.dirname(cfg.stateFile), { recursive: true });
fs.writeFileSync(cfg.stateFile, JSON.stringify(s, null, 2));
}
let state = loadState();
// ─── George helpers ─────────────────────────────────────────────────────────
async function george(pathAndQuery, opts = {}) {
const url = cfg.george.baseUrl + pathAndQuery;
const r = await fetch(url, {
...opts,
headers: { 'Authorization': `Basic ${georgeAuthB64}`, 'Content-Type': 'application/json', ...(opts.headers || {}) },
});
if (!r.ok) throw new Error(`George ${pathAndQuery} → ${r.status} ${await r.text()}`);
return r.json();
}
async function listInboxUnread(account) {
// is:unread in:inbox — anything Gmail marks unread that landed in inbox.
const q = encodeURIComponent('is:unread in:inbox');
const out = await george(`/api/messages?account=${account}&q=${q}&maxResults=20`);
return out.messages || out.items || [];
}
async function getMessage(account, id) {
return george(`/api/messages/${id}?account=${account}`);
}
async function sendReply(account, replyToMessageId, htmlBody) {
return george(`/api/send?account=${account}`, {
method: 'POST',
body: JSON.stringify({
to: 'steveabramsdesigns@gmail.com', // overridden by replyToMessageId resolution server-side
replyToMessageId,
body: htmlBody,
source: 'claude-email-responder',
}),
});
}
// ─── Sender extraction (handles "Name <addr@x>" + raw "addr@x") ─────────────
function senderEmail(fromHeader) {
if (!fromHeader) return null;
const m = fromHeader.match(/<([^>]+)>/);
return (m ? m[1] : fromHeader).trim().toLowerCase();
}
// ─── Plain-text extraction from George message payload ──────────────────────
function extractPlainText(msg) {
// /api/messages/:id returns: { id, threadId, subject, from, to, date, snippet, body, bodyText }
if (msg.bodyText) return msg.bodyText;
if (msg.snippet) return msg.snippet;
if (msg.body) {
// Strip HTML tags and quoted-reply blocks for cleaner Claude input.
const stripped = String(msg.body)
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<').replace(/>/g, '>')
.replace(/\s+/g, ' ')
.trim();
return stripped;
}
return '';
}
function stripQuotedReply(text) {
// Cut at "On <date> ... wrote:" or "From: ... Sent: ..." blocks.
const cuts = [
/\bOn .+? wrote:/s,
/\bFrom job:.*?(\n|$)/, // George's source-tag footer
/^-{2,}\s*$/m, // signature delimiter
/\bFrom: .+?\bSent: /s,
];
let out = text;
for (const re of cuts) {
const m = out.match(re);
if (m && m.index > 20) out = out.slice(0, m.index).trim();
}
return out.trim();
}
// ─── Claude call (via local `claude` CLI under Steve's Pro/Max subscription) ─
function askClaude(subject, fromText, account) {
const nowPT = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles', dateStyle: 'full', timeStyle: 'long' });
const userMsg = [
`Now (Pacific): ${nowPT}`,
`Subject: ${subject || '(no subject)'}`,
`Account: ${account}`,
'',
fromText,
].join('\n');
return new Promise((resolve, reject) => {
const args = [
'-p',
'--append-system-prompt', cfg.claude.system,
'--effort', 'low',
'--disallowedTools', 'Bash Edit Write Read Agent WebSearch WebFetch',
];
// Strip ANTHROPIC_API_KEY so claude CLI uses Steve's Pro/Max OAuth subscription
// (the API key in secrets-manager has $0 credit; subscription does not).
const childEnv = { ...process.env, HOME: '/Users/stevestudio2', USER: 'stevestudio2' };
delete childEnv.ANTHROPIC_API_KEY;
delete childEnv.ANTHROPIC_AUTH_TOKEN;
const child = spawn(CLAUDE_BIN, args, { stdio: ['pipe', 'pipe', 'pipe'], env: childEnv });
let out = '', err = '';
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`claude CLI timeout after ${CLAUDE_TIMEOUT_MS/1000}s`)); }, CLAUDE_TIMEOUT_MS);
child.stdout.on('data', c => out += c);
child.stderr.on('data', c => err += c);
child.on('error', e => { clearTimeout(timer); reject(e); });
child.on('close', code => {
clearTimeout(timer);
if (code !== 0) return reject(new Error(`claude CLI exit ${code}: ${(out||err).slice(0,500)}`));
resolve(out.trim());
});
child.stdin.end(userMsg);
});
}
// ─── Main poll loop ─────────────────────────────────────────────────────────
let polling = false;
async function pollOnce() {
if (polling) return;
polling = true;
let processed = 0, replied = 0, skipped = 0, errors = 0;
try {
for (const account of cfg.accounts) {
try {
const list = await listInboxUnread(account);
for (const m of list) {
processed++;
const id = m.id;
if (state.replied[id]) { skipped++; continue; }
const sender = senderEmail(m.from);
if (!sender || !cfg.whitelist.map(s=>s.toLowerCase()).includes(sender)) {
// Not Steve — record so we don't re-evaluate; keep Gmail unread for him.
state.replied[id] = { ts: Date.now(), reason: 'sender-not-whitelisted', sender };
skipped++;
continue;
}
// Fetch full body
const full = await getMessage(account, id);
const subject = full.subject || m.subject || '';
const bodyText = stripQuotedReply(extractPlainText(full));
if (!bodyText) {
state.replied[id] = { ts: Date.now(), reason: 'empty-body' };
skipped++;
continue;
}
// Skip messages that look like our own outbound (defense-in-depth).
if (bodyText.includes('From job:') && bodyText.includes('claude-email-responder')) {
state.replied[id] = { ts: Date.now(), reason: 'own-outbound' };
skipped++;
continue;
}
console.log(`[responder] handling msg ${id} from ${sender}: ${bodyText.slice(0,80)}…`);
// ─── Directive shortcut (reverse channel into yolo loop) ───
// Steve can reply to any watchdog email with verbs like:
// act on <target> — queue an action for the next yolo tick
// status — reply with autonomy ledger tail
// pause loop / resume loop
// This bypasses Claude (deterministic, free, fast).
const directiveMatch = bodyText.trim().match(/^(act on|pause loop|resume loop|status)\b\s*(.*)$/i);
let answer;
if (directiveMatch) {
const verb = directiveMatch[1].toLowerCase().replace(/\s+/g, '_');
const target = (directiveMatch[2] || '').trim();
const directive = {
ts: new Date().toISOString(),
from: sender,
raw: bodyText.slice(0, 300),
parsed: { verb, target },
thread_id: full.threadId || id,
};
const fname = `${Date.now()}-${verb}.json`;
const dirQueue = '/Users/macstudio3/Projects/_dw-batch/directives';
try {
fs.mkdirSync(dirQueue, { recursive: true });
fs.writeFileSync(`${dirQueue}/${fname}`, JSON.stringify(directive, null, 2));
console.log(`[responder] directive queued: ${fname}`);
if (verb === 'status') {
// Quick status reply — read ledger tail
let tail = '';
try { tail = require('child_process').execSync(`/Users/macstudio3/.claude/data/ledger-tail.sh 15`, { encoding: 'utf8' }); } catch {}
answer = `<p>Directive queued: <code>${fname}</code></p><pre style="font-family:monospace;font-size:11px;background:#f5f5f5;padding:10px">${tail.replace(/[<>]/g, c => c === '<' ? '<' : '>')}</pre>`;
} else {
answer = `<p>Directive queued for next yolo tick: <code>${verb}</code> · target: <code>${target || '(none)'}</code></p><p style="color:#888;font-size:11px">file: ${fname}</p>`;
}
} catch (e) {
console.error('[responder] directive queue failed', e.message);
answer = `<p>Directive parse failed: ${e.message}</p>`;
}
} else {
try { answer = await askClaude(subject, bodyText, account); }
catch (e) { console.error('[responder] claude error', e.message); errors++; continue; }
}
if (!answer) { state.replied[id] = { ts: Date.now(), reason: 'empty-claude-answer' }; continue; }
// Wrap with light HTML so the reply is paragraphed even if Claude returned plain.
const looksHtml = /<\w+[^>]*>/.test(answer);
const html = looksHtml ? answer : `<p>${answer.replace(/\n\n+/g,'</p><p>').replace(/\n/g,'<br>')}</p>`;
try {
const sent = await sendReply(account, id, html);
state.replied[id] = { ts: Date.now(), reason: 'replied', threadId: sent.threadId, replyMessageId: sent.messageId };
replied++;
console.log(`[responder] replied → thread=${sent.threadId} msg=${sent.messageId}`);
} catch (e) {
console.error('[responder] send error', e.message);
errors++;
}
}
} catch (e) {
console.error(`[responder] poll error for ${account}:`, e.message);
state.lastError = { ts: Date.now(), account, msg: e.message };
errors++;
}
}
} finally {
saveState(state);
polling = false;
if (replied || errors) console.log(`[responder] tick: processed=${processed} replied=${replied} skipped=${skipped} errors=${errors}`);
}
}
// ─── HTTP surface (health + manual trigger) ─────────────────────────────────
const app = express();
app.get('/health', (_req, res) => res.json({
ok: true,
accounts: cfg.accounts,
whitelist: cfg.whitelist,
pollIntervalMs: cfg.pollIntervalMs,
repliedCount: Object.values(state.replied).filter(r => r.reason==='replied').length,
totalSeen: Object.keys(state.replied).length,
lastError: state.lastError,
}));
app.post('/poll-now', async (_req, res) => { await pollOnce(); res.json({ ok: true }); });
app.get('/state', (_req, res) => res.json(state));
app.listen(cfg.port, '127.0.0.1', () => {
console.log(`[claude-email-responder] listening on http://127.0.0.1:${cfg.port}`);
console.log(`[claude-email-responder] watching: ${cfg.accounts.join(', ')}`);
console.log(`[claude-email-responder] whitelist: ${cfg.whitelist.join(', ')}`);
console.log(`[claude-email-responder] poll every ${cfg.pollIntervalMs/1000}s`);
});
setInterval(pollOnce, cfg.pollIntervalMs);
// First poll soon after boot.
setTimeout(pollOnce, 3000);