[object Object]

← back to George Gmail

Add drain script: permanently delete info@ drafts older than 30 days

7106f29ea96eddf2d7a064b1c729de30d20b193e · 2026-08-20 11:14:36 -0700 · Steve Abrams

Batched drain (drafts.list caps at 500, no pagination): discovers the
authoritative in:drafts older_than:30d message-id set once, then loops
list->delete-matches until none remain. Quota backoff, dry-run default,
per-deletion jsonl log. Steve-authorized run 2026-08-20: 3665 deleted, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 7106f29ea96eddf2d7a064b1c729de30d20b193e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 20 11:14:36 2026 -0700

    Add drain script: permanently delete info@ drafts older than 30 days
    
    Batched drain (drafts.list caps at 500, no pagination): discovers the
    authoritative in:drafts older_than:30d message-id set once, then loops
    list->delete-matches until none remain. Quota backoff, dry-run default,
    per-deletion jsonl log. Steve-authorized run 2026-08-20: 3665 deleted, 0 failed.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 delete-old-drafts-info.js | 141 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 141 insertions(+)

diff --git a/delete-old-drafts-info.js b/delete-old-drafts-info.js
new file mode 100644
index 0000000..d94fb98
--- /dev/null
+++ b/delete-old-drafts-info.js
@@ -0,0 +1,141 @@
+#!/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 || '';
+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);
+
+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));
+    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;
+  }
+
+  // ---- DRAIN LOOP ----
+  const logFile = `/tmp/george-old-drafts-deleted-${stamp}.jsonl`;
+  let totalOk = 0, totalFail = 0, batch = 0;
+  const target = older.size;
+  while (batch < MAX_BATCHES) {
+    batch++;
+    const drafts = await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`);
+    const matches = (drafts || []).filter((d) => d && d.message && older.has(d.message.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) — deleting...`);
+    for (const t of matches) {
+      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}  remaining-in-set=${older.size}`);
+  console.log(`Deletion log: ${logFile}`);
+  if (older.size > 0) console.log('Some >30d drafts remain (batch cap or failures). Re-run to finish.');
+})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });

← f1e3c56 auto-data-snapshot: 2026-08-20T08:00:08 (1 data files) — dat  ·  back to George Gmail  ·  Schedule daily info@ old-draft drain (launchd) with runaway 0fd5c29 →