← back to AbramsEgo

lib/spend-reviews/adapters/gmail-receipts.js

110 lines

'use strict';
/**
 * Gmail-receipts ingestion adapter — scans Steve's Gmail for purchase/receipt/
 * order-confirmation emails via the LOCAL "George" bridge (:9850), READ-ONLY.
 *
 * George's read/search route + creds are environment-specific, so this probes a
 * set of candidate routes and, if none authenticate, returns a clear needsAction
 * (with the probe status codes) so Steve wires the right George creds/endpoint at
 * activation. It never sends, modifies, or labels mail.
 *
 * interface: module.exports = { ingest };  ingest(opts) -> {ok, items, meta}
 */
const cfg = require('../config');

const DEFAULT_QUERY = 'subject:(receipt OR "order confirmation" OR "your order" OR invoice) newer_than:1y';

async function tryGet(url) {
  try {
    const r = await fetch(url, { headers: { Authorization: cfg.GEORGE_AUTH } });
    let body = null; try { body = await r.json(); } catch (e) { body = null; }
    return { status: r.status, ok: r.ok, body };
  } catch (e) { return { status: 0, ok: false, err: e.message }; }
}

// Pull a message list out of whatever shape a candidate route returns.
function extractList(body) {
  if (!body) return null;
  if (Array.isArray(body)) return body;
  for (const k of ['messages', 'results', 'items', 'threads', 'data', 'emails', 'mail']) {
    if (Array.isArray(body[k])) return body[k];
  }
  return null;
}

function domainToBrand(from) {
  if (!from) return null;
  const m = String(from).match(/@([^> )]+)/);
  if (!m) { const nm = String(from).match(/^"?([^"<]+)"?\s*</); return nm ? nm[1].trim() : null; }
  const host = m[1].replace(/^(mail|email|orders?|no-?reply|noreply|info|receipts?|store|shop)\./, '').split('.');
  const core = host.length >= 2 ? host[host.length - 2] : host[0];
  return core ? core.charAt(0).toUpperCase() + core.slice(1) : null;
}

function parseTotal(text) {
  if (!text) return 0;
  // prefer a "total" near a currency figure, else first currency figure
  const near = String(text).match(/(?:order\s+total|total|amount\s+(?:charged|paid|due)|grand\s+total)[^$]{0,20}\$\s?([0-9][0-9,]*\.[0-9]{2})/i);
  if (near) return parseFloat(near[1].replace(/,/g, ''));
  const any = String(text).match(/\$\s?([0-9][0-9,]*\.[0-9]{2})/);
  return any ? parseFloat(any[1].replace(/,/g, '')) : 0;
}

function parseOrderId(text) {
  const m = String(text || '').match(/(?:order|confirmation|invoice)\s*#?\s*[:]?\s*([A-Z0-9][A-Z0-9-]{4,})/i);
  return m ? m[1] : null;
}

async function ingest(opts) {
  const query = (opts && opts.query) || DEFAULT_QUERY;
  const max = (opts && Number(opts.max)) || 50;
  // 1. George up?
  try {
    const h = await fetch(cfg.GEORGE_URL + '/health');
    if (!h.ok) throw new Error('health ' + h.status);
  } catch (e) {
    return { ok: false, items: [], meta: { source: 'gmail', needsAction: 'george-down',
      note: `George bridge ${cfg.GEORGE_URL} unreachable: ${e.message}` } };
  }
  // 2. discover the read/search route
  const q = encodeURIComponent(query);
  const candidates = [
    `/api/search?q=${q}`, `/api/messages?q=${q}`, `/api/gmail/search?q=${q}`,
    `/api/gmail/messages?q=${q}`, `/api/threads?q=${q}`, `/api/emails?q=${q}`,
    `/api/messages?query=${q}`, `/api/search?query=${q}`, `/api/mail/search?q=${q}`,
  ];
  const probe = {};
  let found = null, list = null;
  for (const c of candidates) {
    const r = await tryGet(cfg.GEORGE_URL + c);
    probe[c] = r.status;
    if (r.ok) { const l = extractList(r.body); if (l) { found = c; list = l; break; } }
  }
  if (!found) {
    const anyAuth = Object.values(probe).some((s) => s === 401 || s === 403);
    return { ok: false, items: [], meta: { source: 'gmail',
      needsAction: anyAuth ? 'george-creds-unknown' : 'george-search-endpoint-unknown',
      note: anyAuth
        ? 'George rejected credentials — set GEORGE_USER/GEORGE_PASS in AbramsEgo .env to the correct George read creds, then re-run ingest.'
        : 'No George search endpoint returned a message list — wire the correct read route.',
      probe } };
  }
  // 3. normalize the message list into spend inputs
  const items = [];
  for (const m of list.slice(0, max)) {
    const from = m.from || m.From || m.sender || (m.headers && m.headers.from) || '';
    const subject = m.subject || m.Subject || (m.headers && m.headers.subject) || '';
    const snippet = m.snippet || m.body || m.text || m.preview || '';
    const dateRaw = m.date || m.internalDate || m.Date || (m.headers && m.headers.date) || null;
    const blob = `${subject}\n${snippet}`;
    const amount = parseTotal(blob);
    const merchant = domainToBrand(from) || (subject ? subject.split(/\s[-|—]\s/)[0].slice(0, 60) : 'Unknown merchant');
    let date = null; try { date = dateRaw ? new Date(isFinite(dateRaw) ? Number(dateRaw) : dateRaw).toISOString().slice(0, 10) : null; } catch (e) {}
    items.push({ merchant, product: null, order_id: parseOrderId(blob), date, amount, currency: 'USD',
      url: null, raw: { from, subject, snippet: String(snippet).slice(0, 300), id: m.id || m.messageId || null } });
  }
  return { ok: true, items, meta: { source: 'gmail', endpoint: found, note: `scanned ${items.length} receipt emails`, scanned: items.length, query } };
}

module.exports = { ingest, domainToBrand, parseTotal };