← back to AbramsOS

lib/mode-corpus.js

113 lines

// lib/mode-corpus.js — keep the local Mode-newsletter corpus (data/mode-emails-*.js) fresh
// from Gmail via the George HTTP bridge, so the claims watch stays autonomous WITHOUT the
// (currently unset) OAuth connector. This is the "refactor the watch onto the George corpus
// path" deliverable (TK-10506).
//
// Design contract:
//   • BEST-EFFORT + NON-BREAKING. Any failure (George down, bridge 401, steve-personal token
//     invalid_grant, unexpected body shape) returns {ok:false, reason} and NEVER throws — the
//     watch always falls through to ingesting the existing corpus.
//   • $0, read-only against Gmail (no sends, no submits). Appends fetched bodies to a dedicated
//     shard data/mode-emails-george.js that the corpus ingester globs in automatically.
//   • Mode newsletter lands in the 'steve-personal' Gmail account (steveabramsdesigns@gmail.com).

const fs = require('fs');
const path = require('path');

const GEORGE_URL = (process.env.GEORGE_URL || '').replace(/\/$/, '');
const GEORGE_BASIC_AUTH = process.env.GEORGE_BASIC_AUTH || '';
const ACCOUNT = process.env.MODE_MAIL_ACCOUNT || 'steve-personal';
const FROM = 'daniel@mail.modemobile.com';
const SHARD = path.join(__dirname, '..', 'data', 'mode-emails-george.js');

function authHeaders() {
  return GEORGE_BASIC_AUTH ? { Authorization: 'Basic ' + GEORGE_BASIC_AUTH } : {};
}

async function gjson(url) {
  const res = await fetch(url, { headers: authHeaders() });
  const text = await res.text();
  let json; try { json = JSON.parse(text); } catch (_) { json = null; }
  if (!res.ok) {
    const msg = (json && (json.error || json.message)) || text.slice(0, 120);
    const err = new Error(`George ${res.status}: ${msg}`);
    err.status = res.status;
    err.invalidGrant = /invalid_grant/i.test(text);
    throw err;
  }
  return json;
}

function loadShard() {
  try { const arr = require(SHARD); return Array.isArray(arr) ? arr : []; }
  catch (_) { return []; }
}

function writeShard(list) {
  const banner = '// data/mode-emails-george.js — AUTO-MAINTAINED by lib/mode-corpus.js.\n' +
    '// Mode-newsletter bodies fetched from Gmail via the George bridge. Do not hand-edit.\n';
  fs.writeFileSync(SHARD, banner + 'module.exports = ' + JSON.stringify(list, null, 2) + ';\n');
}

// Try the known George body endpoints in order; return a plain-text body or null.
async function fetchBody(id) {
  const enc = encodeURIComponent(id);
  const acct = encodeURIComponent(ACCOUNT);
  const candidates = [
    `${GEORGE_URL}/api/message?account=${acct}&id=${enc}&format=text`,
    `${GEORGE_URL}/api/messages/${enc}?account=${acct}`,
    `${GEORGE_URL}/api/message/${enc}?account=${acct}`,
  ];
  for (const url of candidates) {
    try {
      const j = await gjson(url);
      const body = j && (j.body || j.text || j.plain || (j.message && (j.message.body || j.message.text)));
      if (body && String(body).trim()) return String(body);
    } catch (e) { if (e.invalidGrant) throw e; /* try next shape */ }
  }
  return null;
}

/**
 * Refresh the corpus shard from George. Returns {ok, added, reason}.
 * Never throws.
 */
async function refreshFromGeorge({ days = 20 } = {}) {
  if (!GEORGE_URL) return { ok: false, added: 0, reason: 'GEORGE_URL unset' };
  let list;
  try {
    const q = encodeURIComponent(`from:${FROM} newer_than:${days}d`);
    const j = await gjson(`${GEORGE_URL}/api/messages?account=${encodeURIComponent(ACCOUNT)}&maxResults=50&q=${q}`);
    list = j.messages || j || [];
    if (!Array.isArray(list)) list = [];
  } catch (e) {
    // invalid_grant => steve-personal needs a Steve-gated OAuth reconnect. Surface it clearly.
    const reason = e.invalidGrant
      ? `george account '${ACCOUNT}' invalid_grant — reconnect required (gated)`
      : `george list failed: ${e.message}`;
    return { ok: false, added: 0, reason, invalidGrant: !!e.invalidGrant };
  }

  const shard = loadShard();
  const have = new Set(shard.map(e => e.id));
  let added = 0;
  for (const m of list) {
    const id = m.id;
    if (!id || have.has(id)) continue;
    let body = m.body || m.text || null;
    if (!body) { try { body = await fetchBody(id); } catch (e) {
      if (e.invalidGrant) return { ok: false, added, reason: `invalid_grant mid-fetch — reconnect required (gated)`, invalidGrant: true };
      body = null;
    } }
    if (!body) continue;                        // can't parse without a body; skip honestly
    const date = m.internalDate ? new Date(Number(m.internalDate)).toISOString().slice(0, 10)
      : (m.date ? new Date(m.date).toISOString().slice(0, 10) : null);
    shard.push({ id, date, subject: m.subject || '', body });
    have.add(id); added++;
  }
  if (added) writeShard(shard);
  return { ok: true, added, reason: added ? `fetched ${added} new mailer(s)` : 'no new mailers' };
}

module.exports = { refreshFromGeorge, SHARD };