← 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 };