← back to George Gmail

delete-old-drafts-info.js

378 lines

#!/usr/bin/env node
/*
 * delete-old-drafts-info.js  —  drain old drafts from info@designerwallcoverings.com
 *
 * Permanently deletes EVERY draft older than 30 days (Gmail `older_than:30d`).
 * Steve-authorized 2026-08-20 ("delete all drafts over 30 days old", info-office).
 * Confirmed scope: ~3,665 old drafts (garbage sample-follow-up dumps).
 *
 * SAFETY:
 *   - Authoritative age filter: the server-side set `in:drafts older_than:30d`.
 *     A draft is deleted ONLY if its messageId is in that >30d set, so newer
 *     drafts are structurally excluded no matter what drafts.list returns.
 *   - drafts.list caps at 500 (George's route has no pageToken), so deletion
 *     runs as a DRAIN LOOP: list 500 -> delete the >30d matches -> repeat,
 *     until a full pass finds zero matches (or the set is exhausted).
 *   - DRY RUN by default. CONFIRM=1 to actually delete.
 *   - Gmail drafts.delete is PERMANENT (no Trash). Every deletion is logged to
 *     a local jsonl for the record. The full >30d set is cached to a SET file
 *     so a re-run can skip the expensive rediscovery (pass SET=/path).
 *
 * USAGE:
 *   node delete-old-drafts-info.js                 # dry run: counts + writes SET cache
 *   CONFIRM=1 node delete-old-drafts-info.js        # discover (or reuse SET) then drain-delete
 *   SET=/tmp/...set.json CONFIRM=1 node ...          # reuse a cached set, skip discovery
 */
'use strict';
const fs = require('fs');
const path = require('path');

const BASE = process.env.GEORGE_BASE || 'http://127.0.0.1:9850';
const ACC = 'info';
const QUERY = 'in:drafts older_than:30d';
const DELETE = process.env.CONFIRM === '1';
const SET_IN = process.env.SET || '';
// ── KEEP-LIST (TK-11231) ─────────────────────────────────────────────────────
// The drainer was a blanket age filter with NO exemption mechanism, so a draft
// that is READY and WANTED sat on the same 30-day permanent-delete fuse as the
// garbage — that is why a live vendor cost-list ask (Newmor, 1,294 rows / 0
// priced) was days from being destroyed with no Trash. A wanted draft must be
// protectable without sending it. Ids here are NEVER deleted, at any age.
// File: data/drain-keep-list.json -> { "<messageId>": "why it is kept" }
const HB_PATH_EARLY = path.join(__dirname, 'data', 'drain-old-drafts-latest.json');
// TK-11552: DRAIN_KEEP_LIST is a TEST-ONLY seam (mirrors the canary's env of the same
// name) so the exemption logic can be exercised against a fixture without swapping the
// real keep-list on disk. The launchd plist must NEVER set it. VERIFIED 2026-09-13: the
// plist DOES have an EnvironmentVariables block (CONFIRM=1, MAX_AUTO_DELETE=800, PATH)
// but does NOT set DRAIN_KEEP_LIST, so a scheduled run always reads the real file.
// If that block ever gains DRAIN_KEEP_LIST, a scheduled drain would silently run against
// a fixture and delete everything the real list protects — treat adding it as a defect.
const KEEP_PATH = process.env.DRAIN_KEEP_LIST || path.join(__dirname, 'data', 'drain-keep-list.json');
// FAIL CLOSED (TK-11552). This loader used to be
//     try { ...readFileSync... } catch (_) { return {}; }
// which failed OPEN: a corrupted, truncated, or deleted keep-list silently
// became "zero exemptions", and the very next 04:15 run would PERMANENTLY
// delete (no Trash) every draft the keep-list existed to protect. The whole
// protection rested on a file whose read failure was swallowed.
//
// A no-Trash deleter must never infer "nothing is protected" from "I could not
// read what is protected". So: any failure to load a usable keep-list ABORTS
// the deletion pass. ENOENT aborts too — "I want no exemptions" must be stated
// explicitly by writing `{}`, which takes a second and is a deliberate act,
// whereas an absent file is indistinguishable from one that went missing.
// Dry runs (CONFIRM unset) only warn: they delete nothing, so they stay usable
// for diagnosis even when the keep-list is broken.
let KEEP = {};
let KEEP_LOAD_ERROR = null;
try {
  const parsed = JSON.parse(fs.readFileSync(KEEP_PATH, 'utf8'));
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
    throw new Error(`keep-list must be a JSON object of {messageId: reason}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
  }
  KEEP = parsed;
} catch (e) {
  KEEP_LOAD_ERROR = `${e.code === 'ENOENT' ? 'MISSING' : 'UNREADABLE'}: ${e.message}`;
}
const KEEP_IDS = new Set(Object.keys(KEEP));
if (KEEP_LOAD_ERROR) {
  const msg = `keep-list could not be loaded (${KEEP_PATH}) -> ${KEEP_LOAD_ERROR}`;
  if (process.env.CONFIRM === '1') {
    console.error(`ABORT: ${msg}`);
    console.error('Refusing to permanently delete while the exemption list is unknown.');
    console.error(`Fix the file, or write '{}' to it to state explicitly that nothing is exempt.`);
    try {
      fs.writeFileSync(HB_PATH_EARLY, JSON.stringify({
        ts: new Date().toISOString(), skill: 'delete-old-drafts-info', account: 'info',
        verdict: 'FAIL', status: 'FAIL', deleted: 0, failed: 0,
        note: `aborted before deleting: ${msg}`,
      }, null, 2));
    } catch (_) {}
    process.exit(1);
  }
  console.warn(`WARN: ${msg} (dry run continues; a CONFIRM=1 run would ABORT)`);
} else if (KEEP_IDS.size) {
  console.log(`keep-list: ${KEEP_IDS.size} draft(s) exempt from deletion at any age`);
}
// TK-11552 (2026-09-13): exempt on EITHER id. Gmail ROTATES message.id on every draft
// save, while the DRAFT id (d.id, the one we actually delete by) is stable. A keep-list
// keyed only on message.id therefore SILENTLY loses protection the moment a human edits a
// protected draft — proven live: draft r-5517420768746463086 kept its id while its message
// id went 1a09ba5cc706157b -> 1a09ba5ce13ee153 across one edit. Matching either id is
// strictly preservative: it can only ADD exemptions, never remove one.
const isExempt = (d) => !!d && (KEEP_IDS.has(d.message && d.message.id) || KEEP_IDS.has(d.id));
const PAGE = parseInt(process.env.PAGE || '40', 10);
const PAGE_SLEEP = parseInt(process.env.PAGE_SLEEP || '3000', 10);
const DEL_SLEEP = parseInt(process.env.DEL_SLEEP || '250', 10);
const MAX_BATCHES = parseInt(process.env.MAX_BATCHES || '40', 10);
// Runaway guard for unattended runs: if the >30d set exceeds this, DO NOT
// auto-delete — flag for a human. 0/unset = no cap (manual runs).
const MAX_AUTO_DELETE = parseInt(process.env.MAX_AUTO_DELETE || '0', 10);
const HB_PATH = path.join(__dirname, 'data', 'drain-old-drafts-latest.json');
function heartbeat(obj) {
  try { fs.writeFileSync(HB_PATH, JSON.stringify({ ts: new Date().toISOString(), skill: 'delete-old-drafts-info', account: ACC, ...obj }, null, 2)); } catch (_) {}
}

function resolveAuth() {
  if (process.env.GEORGE_AUTH && process.env.GEORGE_AUTH.includes(':')) {
    const [u, ...rest] = process.env.GEORGE_AUTH.split(':');
    return { u, p: rest.join(':') };
  }
  let u = 'admin', p = process.env.GEORGE_BASIC_AUTH_PASS || '';
  const envPath = path.join(process.env.HOME || '', 'Projects/Designer-Wallcoverings/DW-MCP/.env');
  try {
    const t = fs.readFileSync(envPath, 'utf8');
    const m = t.match(/^GEORGE_BASIC_AUTH=(.+)$/m);
    if (m) { const v = m[1].trim(); if (v.includes(':')) { const s = v.split(':'); u = s[0]; p = s.slice(1).join(':'); } }
    if (!p) { const mp = t.match(/^GEORGE_BASIC_AUTH_PASS=(.+)$/m); if (mp) p = mp[1].trim(); }
  } catch (_) { /* fall back */ }
  return { u, p };
}

const { u, p } = resolveAuth();
const H = { Authorization: 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64') };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function jreq(url, opts = {}, tries = 8) {
  let wait = 20000;
  for (let i = 0; i < tries; i++) {
    let r;
    try { r = await fetch(url, { ...opts, headers: H }); }
    catch (e) { if (i === tries - 1) throw e; await sleep(wait); wait = Math.min(wait * 1.6, 120000); continue; }
    if (r.ok) return r;
    const body = (await r.text()).slice(0, 300);
    const quota = r.status === 429 || /Quota exceeded|rateLimitExceeded|userRateLimit/i.test(body);
    if (quota && i < tries - 1) { console.warn(`  quota (${r.status}); backoff ${Math.round(wait / 1000)}s`); await sleep(wait); wait = Math.min(wait * 1.6, 120000); continue; }
    throw new Error(`${opts.method || 'GET'} ${r.status} ${url} :: ${body}`);
  }
}
const jget = async (url) => (await jreq(url)).json();

async function discoverSet() {
  const older = new Set();
  let token = '', page = 0;
  do {
    const url = `${BASE}/api/messages?account=${ACC}&q=${encodeURIComponent(QUERY)}&maxResults=${PAGE}` + (token ? `&pageToken=${token}` : '');
    const d = await jget(url);
    (d.messages || []).forEach((m) => m && m.id && older.add(m.id));
    token = d.nextPageToken || '';
    page++;
    process.stdout.write(`\r  discovery page ${page}: ${older.size} >30d draft-msgs   `);
    if (token) await sleep(PAGE_SLEEP);
  } while (token);
  console.log('');
  return older;
}

(async () => {
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');

  // ---- build / load the authoritative >30d message-id set ----
  let older;
  if (SET_IN) {
    older = new Set(JSON.parse(fs.readFileSync(SET_IN, 'utf8')));
    console.log(`loaded >30d set from ${SET_IN}: ${older.size} ids`);
  } else {
    older = await discoverSet();
    const setFile = `/tmp/george-old-drafts-set-${stamp}.json`;
    fs.writeFileSync(setFile, JSON.stringify([...older]));
    console.log(`>30d draft messages: ${older.size}   (set cached: ${setFile})`);
  }

  if (!DELETE) {
    // dry-run: show how many of the first 500 drafts are deletable right now
    const drafts = await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`);
    const matches = (drafts || []).filter((d) => d && d.message && older.has(d.message.id) && !isExempt(d));
    console.log(`\n--- DRY RUN (no deletions). CONFIRM=1 to drain. ---`);
    console.log(`total >30d drafts to remove : ${older.size}`);
    console.log(`deletable in first 500 window: ${matches.length}`);
    console.log(`(deletion runs as a drain loop across ~${Math.ceil(older.size / 422)} batches)`);
    return;
  }

  // ---- runaway guard (unattended runs) ----
  if (MAX_AUTO_DELETE > 0 && older.size > MAX_AUTO_DELETE) {
    console.error(`ABORT: >30d set (${older.size}) exceeds MAX_AUTO_DELETE (${MAX_AUTO_DELETE}). Not auto-deleting — run manually to review.`);
    heartbeat({ verdict: 'WARN', status: 'WARN', deleted: 0, failed: 0, older_set: older.size, cap: MAX_AUTO_DELETE, note: 'backlog exceeds cap; auto-delete skipped, human review needed' });
    process.exit(0);
  }

  // ---- PROTECTED-DRAFT SURVIVAL CENSUS (TK-11552) ----
  // The exemption filter and the success verdict used to be the SAME code path:
  // `matches` was built with `!isExempt(d)` and the verdict was
  // `totalFail > 0 ? 'WARN' : 'PASS'`. If isExempt were wrong, the run would
  // permanently delete (no Trash) protected drafts AND report PASS.
  //
  // CRITICAL (adversarial review, 2026-09-13): the FIRST version of this census had the
  // same disease one level up — it built the protected set by calling isExempt(), the
  // very predicate it was meant to audit. A PARTIAL false-negative (say 8 of 11 keep-
  // listed drafts recognized) would drop the other 3 out of BOTH the census and the
  // exemption: deleted, never missed, reported PASS. Two paths cross-check each other
  // only if they can DISAGREE, which requires different code even when they compute the
  // same set today. So the protected set below is resolved from KEEP_IDS by direct map
  // lookup and isExempt() is never consulted here. If the two ever disagree, that
  // disagreement is caught BEFORE any delete (see the contradiction gate in the loop).
  const asList = (x) => (Array.isArray(x) ? x : []);
  const censusArr = asList(await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`));
  const censusTruncated = censusArr.length >= 500;
  const byDraftId = new Map(), byMsgId = new Map();
  for (const d of censusArr) {
    if (!d) continue;
    if (d.id) byDraftId.set(d.id, d);
    if (d.message && d.message.id) byMsgId.set(d.message.id, d);
  }
  const protectedBefore = new Map(); // live draftId -> the keep-list id that resolved it
  const keepUnresolved = [];
  for (const kid of KEEP_IDS) {
    const d = byDraftId.get(kid) || byMsgId.get(kid);
    if (d && d.id) protectedBefore.set(d.id, kid); else keepUnresolved.push(kid);
  }
  console.log(`protected-draft census: ${protectedBefore.size} of ${KEEP_IDS.size} keep-list id(s) resolve to a LIVE draft` +
    (keepUnresolved.length ? `  (${keepUnresolved.length} unresolved)` : ''));
  // Was the invariant actually exercised this run? Zero live protected drafts means the
  // run proves nothing about protection, which must not be reported as "verified".
  const protectionMeasured = protectedBefore.size > 0 && !censusTruncated;

  // The listing is capped at 500. If it came back full we cannot see the whole mailbox,
  // so we can neither census every protected draft nor trust the survival check (a
  // protected draft beyond the window reads as "missing" either way). Deleting
  // permanently while blind to what is protected is the one thing this script must not
  // do, so it refuses. Only bites when a keep-list is actually in force.
  if (censusTruncated && KEEP_IDS.size > 0) {
    console.error(`ABORT: draft listing came back full (${censusArr.length} = the 500 cap) while ${KEEP_IDS.size} keep-list id(s) are in force.`);
    console.error('Cannot see the whole mailbox, so protection cannot be verified. Refusing to permanently delete. Prune the keep-list or drain with a reviewed SET file.');
    heartbeat({ verdict: 'FAIL', status: 'FAIL', deleted: 0, failed: 0, archived: 0,
      protected_before: protectedBefore.size, protected_verified: false, census_truncated: true,
      note: 'ABORTED before any delete: draft listing truncated at 500 while a keep-list is in force — protection unverifiable' });
    process.exit(1);
  }

  // Keep-listed drafts that were live at census time and are ABSENT from `list` now.
  // An empty/unparseable listing is reported as a READ FAILURE, not as "everything was
  // lost" — same safe action (abort), but the reason must not be a fabrication.
  const lostProtected = (list) => {
    const arr = asList(list);
    if (arr.length === 0 && protectedBefore.size > 0) return { readFailed: true, lost: [] };
    const live = new Set(arr.map((d) => d && d.id));
    return { readFailed: false, lost: [...protectedBefore.keys()].filter((id) => !live.has(id)) };
  };
  const abortRun = (note, extra) => {
    console.error(`\nABORT: ${note}`);
    console.error('Deletion here is permanent (no Trash). Stopping the pass.');
    heartbeat({ verdict: 'FAIL', status: 'FAIL', protected_before: protectedBefore.size,
      protected_verified: false, census_truncated: censusTruncated, note, ...extra });
    process.exit(1);
  };

  // ---- DRAIN LOOP ----
  const logFile = `/tmp/george-old-drafts-deleted-${stamp}.jsonl`;
  // Archive path (TK-11609): fetch and save draft metadata+snippet BEFORE permanent-delete so
  // there is a local recovery record. Failure to archive is non-fatal — we log but still delete.
  const archiveFile = path.join(__dirname, 'data', `archive-before-delete-${stamp.slice(0, 10)}.jsonl`);
  let totalOk = 0, totalFail = 0, totalArchived = 0, batch = 0;
  const target = older.size;
  console.log(`Archive path: ${archiveFile}`);
  while (batch < MAX_BATCHES) {
    batch++;
    const drafts = asList(await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`));
    // Survival check BEFORE this batch deletes anything.
    const chk = lostProtected(drafts);
    if (chk.readFailed) abortRun(`batch ${batch}: draft listing came back empty while ${protectedBefore.size} draft(s) are under protection — refusing to delete blind`,
      { deleted: totalOk, failed: totalFail, archived: totalArchived, remaining_in_set: older.size });
    if (chk.lost.length) abortRun(`batch ${batch}: ${chk.lost.length} keep-listed draft(s) are no longer present: ${chk.lost.join(', ')}`,
      { deleted: totalOk, failed: totalFail, archived: totalArchived, remaining_in_set: older.size,
        protected_lost_count: chk.lost.length, protected_lost_ids: chk.lost });
    const matches = drafts.filter((d) => d && d.message && older.has(d.message.id) && !isExempt(d));
    // CONTRADICTION GATE. The keep-list (resolved above, independently) says protect;
    // isExempt() says delete. They disagree, so one of them is wrong — and on an
    // irreversible delete the only safe reading is the protective one. Catching this
    // BEFORE the delete turns a partial isExempt false-negative from "permanent loss
    // detected afterwards" into "loss prevented", which is the whole point.
    const contradictions = matches.filter((m) => m && protectedBefore.has(m.id));
    if (contradictions.length) abortRun(`batch ${batch}: ${contradictions.length} draft(s) are keep-list protected but the exemption filter selected them for deletion: ${contradictions.map((m) => m.id).join(', ')}`,
      { deleted: totalOk, failed: totalFail, archived: totalArchived, remaining_in_set: older.size,
        contradiction_count: contradictions.length, contradiction_ids: contradictions.map((m) => m.id) });
    if (matches.length === 0) { console.log(`\nbatch ${batch}: 0 matches — drain complete.`); break; }
    console.log(`\nbatch ${batch}: ${matches.length} matches (of ${Array.isArray(drafts) ? drafts.length : '?'} listed) — archiving then deleting...`);
    for (const t of matches) {
      // Archive before delete: fetch message headers + snippet for recovery
      let archiveEntry = { ts: new Date().toISOString(), draftId: t.id, messageId: t.message.id, archived: false };
      try {
        const msg = await jget(`${BASE}/api/messages/${t.message.id}?account=${ACC}`);
        if (msg) {
          archiveEntry = { ...archiveEntry, subject: msg.subject, from: msg.from, to: msg.to, date: msg.date, snippet: msg.snippet, archived: true };
          totalArchived++;
        }
      } catch (_) { /* non-fatal — delete proceeds regardless */ }
      try {
        fs.appendFileSync(archiveFile, JSON.stringify(archiveEntry) + '\n');
      } catch (_) {}
      // Last gate before the irreversible call. Costs nothing (a Map lookup) and closes
      // the in-batch window: without it, a batch of up to 500 deletes would only be
      // re-checked on the NEXT batch.
      if (protectedBefore.has(t.id)) abortRun(`refused to delete keep-list protected draft ${t.id} — the exemption filter should never have selected it`,
        { deleted: totalOk, failed: totalFail, archived: totalArchived, remaining_in_set: older.size,
          contradiction_count: 1, contradiction_ids: [t.id] });
      try {
        await jreq(`${BASE}/api/drafts/${t.id}?account=${ACC}`, { method: 'DELETE' });
        fs.appendFileSync(logFile, JSON.stringify({ ts: new Date().toISOString(), draftId: t.id, messageId: t.message.id }) + '\n');
        older.delete(t.message.id);
        totalOk++;
        if (totalOk % 50 === 0) process.stdout.write(`\r  deleted ${totalOk}/${target}   `);
      } catch (e) { totalFail++; console.error(`\n  FAIL draft=${t.id}: ${e.message}`); }
      await sleep(DEL_SLEEP);
    }
  }
  console.log(`\n\nDONE. deleted=${totalOk}  failed=${totalFail}  archived=${totalArchived}  remaining-in-set=${older.size}`);
  console.log(`Deletion log: ${logFile}`);
  console.log(`Archive (recovery): ${archiveFile}`);
  if (older.size > 0) console.log('Some >30d drafts remain (batch cap or failures). Re-run to finish.');

  // ---- FINAL PROTECTED-DRAFT ASSERTION (TK-11552) ----
  // Re-read the mailbox through the same listing route and confirm every draft the
  // keep-list was protecting is still there. This is the only statement in this script
  // that is about the OUTCOME rather than the intent.
  let lostFinal = [];
  let assertionRead = true;
  try {
    const fin = lostProtected(await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`));
    if (fin.readFailed) assertionRead = false; else lostFinal = fin.lost;
  } catch (e) {
    // Could not re-read => could not verify. That is NOT-MEASURED, not "fine".
    assertionRead = false;
    console.error(`post-run protection re-read FAILED: ${e.message}`);
  }

  let verdict = totalFail > 0 ? 'WARN' : 'PASS';
  let protectionNote = null;
  let protectedVerified = null;
  if (lostFinal.length) {
    verdict = 'FAIL';
    protectedVerified = false;
    protectionNote = `${lostFinal.length} keep-listed draft(s) LOST during this run — permanent, no Trash`;
    console.error(`\nPROTECTION FAILURE: ${lostFinal.join(', ')}`);
  } else if (!assertionRead) {
    // Could not verify. If this run deleted nothing there was nothing to lose, so WARN.
    // If it DID delete, an unverifiable outcome on an irreversible operation is a FAIL —
    // "I could not check" must not be quieter than "I checked and it was fine".
    verdict = totalOk > 0 ? 'FAIL' : (verdict === 'PASS' ? 'WARN' : verdict);
    protectionNote = `post-run re-read failed — protection NOT verified after ${totalOk} permanent deletion(s)`;
  } else if (protectedBefore.size === 0 && KEEP_IDS.size > 0) {
    // The keep-list is non-empty yet nothing it names is a live draft. Either every
    // held item was sent/dropped (fine, prune the list) or the ids have rotted. Either
    // way the protection was not exercised, so this run proves nothing about it.
    verdict = verdict === 'PASS' ? 'WARN' : verdict;
    protectionNote = `keep-list holds ${KEEP_IDS.size} id(s) but none resolve to a live draft — protection not exercised`;
  } else if (protectionMeasured) {
    protectedVerified = true;
    console.log(`protection VERIFIED: all ${protectedBefore.size} keep-listed draft(s) survived this run.`);
  }
  if (protectionNote) console.log(`protection: ${protectionNote}`);

  heartbeat({ verdict, status: verdict, deleted: totalOk, failed: totalFail, archived: totalArchived,
    remaining_in_set: older.size, archive_path: archiveFile,
    protected_before: protectedBefore.size, protected_lost_count: lostFinal.length,
    protected_lost_ids: lostFinal, protected_verified: protectedVerified,
    keep_list_ids: KEEP_IDS.size, keep_list_unresolved_count: keepUnresolved.length,
    census_truncated: censusTruncated, protection_note: protectionNote });
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });