← back to Discontinued Agent

lib/george.js

111 lines

// George (DW Gmail HTTP agent) client — read-only usage here.
// George runs locally on http://127.0.0.1:9850. Health needs no auth; the
// data routes may require HTTP Basic (admin:<pw>). We reuse the same keychain
// item george-mcp uses (service "dw-agents", account "admin") when
// GEORGE_BASIC_AUTH is not supplied in the env.
//
// Cost: George is local / self-hosted = $0 per call.

import { execSync } from 'node:child_process';

const BASE_URL = (process.env.GEORGE_URL || 'http://127.0.0.1:9850').replace(/\/$/, '');
const ACCOUNT = process.env.GEORGE_ACCOUNT || 'info';

function keychainAuth() {
  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;
  }
}

let _auth; // memoized: undefined = not resolved, null = none
function basicAuth() {
  if (_auth !== undefined) return _auth;
  _auth = process.env.GEORGE_BASIC_AUTH || keychainAuth() || null;
  return _auth;
}

async function george(path, { query } = {}) {
  const url = new URL(BASE_URL + path);
  if (query) {
    for (const [k, v] of Object.entries(query)) {
      if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
    }
  }
  const headers = {};
  const auth = basicAuth();
  if (auth) headers.Authorization = `Basic ${auth}`;
  const res = await fetch(url, { headers });
  const text = await res.text();
  let data;
  try {
    data = JSON.parse(text);
  } catch {
    data = { raw: text };
  }
  if (!res.ok) {
    const err = new Error(`George GET ${path} -> ${res.status} ${res.statusText}: ${text.slice(0, 200)}`);
    err.status = res.status;
    throw err;
  }
  return data;
}

export async function health() {
  // Public, unauthenticated liveness route. (/api/health requires Basic auth.)
  const res = await fetch(`${BASE_URL}/health`);
  if (!res.ok) throw new Error(`George health ${res.status}`);
  return res.json().catch(() => ({ status: 'ok' }));
}

// List messages matching a Gmail search query. Returns [{id,threadId,subject,from,to,date,snippet}].
export async function listMessages(query, { maxResults = 50 } = {}) {
  const data = await george('/api/messages', {
    query: { q: query, maxResults, account: ACCOUNT },
  });
  // George returns { messages: [...] } or a bare array depending on version.
  return data.messages || data.results || (Array.isArray(data) ? data : []);
}

// Fetch a full message (headers + body + attachment metadata) by id.
export async function getMessage(id) {
  return george(`/api/messages/${encodeURIComponent(id)}`, { query: { account: ACCOUNT } });
}

// POST helper (Basic-auth reused). Used for the run-completion report email.
async function georgePost(path, payload) {
  const headers = { 'Content-Type': 'application/json' };
  const auth = basicAuth();
  if (auth) headers.Authorization = `Basic ${auth}`;
  const res = await fetch(BASE_URL + path, {
    method: 'POST',
    headers,
    body: JSON.stringify(payload),
  });
  const text = await res.text();
  let data;
  try {
    data = JSON.parse(text);
  } catch {
    data = { raw: text };
  }
  if (!res.ok) {
    const err = new Error(`George POST ${path} -> ${res.status}: ${text.slice(0, 200)}`);
    err.status = res.status;
    throw err;
  }
  return data;
}

// Send a plain email (used for the info@ run-completion report). Sending
// info@ -> info@ is internal, so it clears George's external-send guard.
export async function sendMail({ to, subject, body, account = ACCOUNT, source = 'discontinued-agent' }) {
  return georgePost('/api/send', { to, subject, body, account, source });
}

export { ACCOUNT, BASE_URL };