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