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