← back to Draft Viewer
Draft triage viewer — 5 ready to send, 21 to delete, deep-linked to Gmail
e93ef886aea8bc9610cf3f0aef591d0ee3e41ab0 · 2026-09-04 10:40:58 -0700 · Steve Abrams
Basic-auth board over the unsent agent-composed drafts found in TK-11231.
Green = ready to send (with the catalog gap each one closes), red = delete,
amber = the 13 stale customer replies. Density slider + created-time chip per
the standing admin-card rules. Snapshot in data/drafts.json; refresh.mjs will
repopulate once George's list endpoint auth is sorted (currently 401).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KwSKeavk6pXdx4CymWBXmz
Files touched
A .gitignoreA data/drafts.jsonA refresh.mjsA server.js
Diff
commit e93ef886aea8bc9610cf3f0aef591d0ee3e41ab0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 4 10:40:58 2026 -0700
Draft triage viewer — 5 ready to send, 21 to delete, deep-linked to Gmail
Basic-auth board over the unsent agent-composed drafts found in TK-11231.
Green = ready to send (with the catalog gap each one closes), red = delete,
amber = the 13 stale customer replies. Density slider + created-time chip per
the standing admin-card rules. Snapshot in data/drafts.json; refresh.mjs will
repopulate once George's list endpoint auth is sorted (currently 401).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KwSKeavk6pXdx4CymWBXmz
---
.gitignore | 4 ++++
data/drafts.json | 34 +++++++++++++++++++++++++++++++++
refresh.mjs | 27 ++++++++++++++++++++++++++
server.js | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 123 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1bcdbdc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.env*
+*.log
+.DS_Store
diff --git a/data/drafts.json b/data/drafts.json
new file mode 100644
index 0000000..8ce9241
--- /dev/null
+++ b/data/drafts.json
@@ -0,0 +1,34 @@
+{
+ "generated": "2026-09-04",
+ "note": "Snapshot verified via in:draft search. Bodies live in Gmail — cards deep-link there.",
+ "ready": [
+ {"id":"1a06d5e2baaf3149","account":"steve-office","to":"Customerservice@nationalsolutions.com","vendor":"Command Wallcovering","subject":"Net cost list request — full Command Wallcovering line","items":2120,"gap":"2,120 rows · 0 trade · 0 retail","created":"2026-09-04T10:01:22"},
+ {"id":"1a06d5e530b2ec40","account":"steve-office","to":"info@vahallan.com","vendor":"Vahallan","subject":"Net cost list request — full Vahallan line","items":390,"gap":"390 rows · no cost column","created":"2026-09-04T10:01:33"},
+ {"id":"1a06d55e4f511b8e","account":"steve-office","to":"samples@rwltd.com","vendor":"Reid Witlin","subject":"Net cost list request — full Reid Witlin line","items":1470,"gap":"1,470 rows · 0 cost (1,070 price only)","created":"2026-09-04T09:52:20"},
+ {"id":"1a06d2ea705dddd3","account":"steve-office","to":"tokiwausa@tokiwa.net","vendor":"Fentucci Naturals","subject":"Net cost list request — Fentucci Naturals grasscloth","items":148,"gap":"148 rows · no cost column","created":"2026-09-04T09:09:29"},
+ {"id":"1a06d1ef99f846d1","account":"info","to":"JCarol@lbiboyd.com · cc stephanie@","vendor":"Newmor / LBI Boyd","subject":"Newmor pricing — 3 quick questions","items":1294,"gap":"1,294 rows · 0 priced · 305 products dark","created":"2026-09-04T08:52:21"}
+ ],
+ "delete": [
+ {"id":"19f3e20f58596b3e","account":"steve-office","subject":"Command Wallcovering — full price list request","why":"superseded by today's fresh draft","age":"Jul 7"},
+ {"id":"19f3e1c8c8a01b4d","account":"steve-office","subject":"Request: Full Command Wallcovering price list","why":"DUPLICATE — written 3h after the one above, same day","age":"Jul 7"},
+ {"id":"19f906c4a8f0e8fe","account":"steve-office","subject":"Trade / wholesale pricing request — Vahallan","why":"superseded by today's fresh draft","age":"Jul 23"},
+ {"id":"19f3d1a86a49b8c6","account":"steve-office","subject":"Pricing request — Dr. Feelgood Henna","why":"WRONG SCOPE — asks 1 SKU, catalog needs 1,470","age":"Jul 7"},
+ {"id":"1a059915205816a2","account":"steve-office","subject":"Net Cost Request — Fentucci Naturals (118 SKUs)","why":"BROKEN — promises an attachment that isn't there; count wrong (118 vs 148)","age":"Aug 31"},
+ {"id":"1a015db4fe26802c","account":"info","subject":"Request: current 2026 wholesale cost list — Newmor","why":"superseded — bundled 5 lines into one cold ask","age":"Aug 18"},
+ {"id":"1a0691afe4c69d0d","account":"info","subject":"Newmor 2026 Cost List (was: RE: Samples sm Marcus)","why":"superseded — missing all 3 binding questions","age":"Sep 3"},
+ {"id":"1a059922f619b980","account":"info","subject":"Width spec request — 2 discontinued Maya Romanoff patterns","why":"DUPLICATE of the steve-office copy, 2 min apart","age":"Aug 31"},
+ {"id":"19ff677a456e1e61","account":"steve-office","subject":"Willem — Lee Jofa Levens Velvet quote ($476.85/yd)","why":"customer reply, 23 days stale — your call: delete","age":"Aug 12","customer":true},
+ {"id":"19ff67c7ca93d3ee","account":"steve-office","subject":"Dena — Graham & Brown Weave Emerald stock check","why":"customer reply, 23 days stale","age":"Aug 12","customer":true},
+ {"id":"19ff677c9748dd70","account":"steve-office","subject":"Jane — yes you can buy the Merlot pillows","why":"customer reply, 23 days stale","age":"Aug 12","customer":true},
+ {"id":"19ff677d1895b6f4","account":"steve-office","subject":"Laura — ready to place Wolf Gordon Forge order","why":"customer reply, 23 days stale","age":"Aug 12","customer":true},
+ {"id":"19ff67c4e29d46c2","account":"steve-office","subject":"David — Roberto Cavalli RC21003 pricing","why":"customer reply, 23 days stale","age":"Aug 12","customer":true},
+ {"id":"19ff67c3b9d347b7","account":"steve-office","subject":"Nancy — Lee Jofa Lagoon sample","why":"customer reply, 23 days stale","age":"Aug 12","customer":true},
+ {"id":"19ff67c334844485","account":"steve-office","subject":"Leslie — Fleur de Lis coverage + pricing","why":"customer reply, 23 days stale","age":"Aug 12","customer":true},
+ {"id":"19ff67c288b79265","account":"steve-office","subject":"Rita — roll count for 368 sq ft bedroom","why":"customer reply, 23 days stale","age":"Aug 12","customer":true},
+ {"id":"19ff67c1ef9c846b","account":"steve-office","subject":"Shani — expedited samples","why":"customer reply, 23 days stale","age":"Aug 12","customer":true},
+ {"id":"19ff67c543906d8f","account":"steve-office","subject":"Andrew — Fidelity Flora NT65 / Glamour CM178","why":"customer reply, 23 days stale","age":"Aug 12","customer":true},
+ {"id":"19ff67c5f7a58198","account":"steve-office","subject":"Jeanette — glass beaded for art project","why":"customer reply, 23 days stale","age":"Aug 12","customer":true},
+ {"id":"19ff67c6cc0dc4e4","account":"steve-office","subject":"Bonnee — Brand McKenzie Happy Hour colorway","why":"customer reply, 23 days stale","age":"Aug 12","customer":true},
+ {"id":"19ff67c7686cc8dc","account":"steve-office","subject":"Michael — bamboo mural identification","why":"customer reply, 23 days stale","age":"Aug 12","customer":true}
+ ]
+}
diff --git a/refresh.mjs b/refresh.mjs
new file mode 100644
index 0000000..934612c
--- /dev/null
+++ b/refresh.mjs
@@ -0,0 +1,27 @@
+#!/usr/bin/env node
+// refresh.mjs — pull the CURRENT unsent agent-composed drafts from George into data/drafts.json.
+// George exposes create_draft only over MCP, but the local HTTP API can list. If it can't,
+// we keep the last snapshot rather than blanking the viewer (never show a false empty).
+import { writeFileSync, existsSync, readFileSync } from 'node:fs';
+import { execSync } from 'node:child_process';
+const OUT = new URL('./data/drafts.json', import.meta.url).pathname;
+const pass = (() => {
+ try { return execSync(`grep -m1 '^GEORGE_BASIC_AUTH_PASS=' ~/Projects/secrets-manager/.env | cut -d= -f2-`,
+ {shell:'/bin/bash'}).toString().trim(); } catch { return ''; }
+})();
+const accounts = ['info','steve-office'];
+const out = [];
+for (const acct of accounts) {
+ for (const q of ['in:draft newer_than:90d']) {
+ try {
+ const url = `http://127.0.0.1:9850/api/gmail/list?account=${acct}&q=${encodeURIComponent(q)}&max=100`;
+ const r = await fetch(url, { headers: { Authorization: 'Basic ' + Buffer.from('admin:'+pass).toString('base64') } });
+ if (!r.ok) throw new Error('HTTP '+r.status);
+ const j = await r.json();
+ for (const m of (j.messages||j||[])) out.push({ ...m, account: acct });
+ } catch (e) { console.error(`[warn] ${acct}: ${e.message} — keeping existing snapshot`); }
+ }
+}
+if (out.length) { writeFileSync(OUT, JSON.stringify({ fetched_at: new Date().toISOString(), live:true, drafts: out }, null, 2)); console.log(`wrote ${out.length} drafts`); }
+else if (existsSync(OUT)) { console.log('live fetch failed — snapshot retained'); }
+else { console.error('no live fetch and no snapshot'); process.exit(1); }
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..3457af3
--- /dev/null
+++ b/server.js
@@ -0,0 +1,58 @@
+const http=require('http'),fs=require('fs'),path=require('path');
+const USER='admin',PASS='DW2024!';
+const DATA=path.join(__dirname,'data','drafts.json');
+const gm=(acct,id)=>`https://mail.google.com/mail/u/${acct==='info'?'1':'0'}/#drafts/${id}`;
+const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+function page(d){
+ const card=(x,kind)=>`<article class="c ${kind}${x.customer?' cust':''}">
+ <header><span class="v">${esc(x.vendor||x.subject.split('—')[0])}</span>
+ <span class="acct">${esc(x.account)}</span></header>
+ <p class="s">${esc(x.subject)}</p>
+ ${x.to?`<p class="to">→ ${esc(x.to)}</p>`:''}
+ ${x.gap?`<p class="gap">${esc(x.gap)}</p>`:''}
+ ${x.why?`<p class="why">${esc(x.why)}</p>`:''}
+ <footer><time>🕓 ${esc(x.created?new Date(x.created).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):x.age)}</time>
+ <a href="${gm(x.account,x.id)}" target="_blank" rel="noopener noreferrer">open in gmail →</a></footer>
+ <code>${esc(x.id)}</code></article>`;
+ return `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
+<title>Drafts — ${d.ready.length} ready · ${d.delete.length} to delete</title><style>
+:root{--bg:#0e0e10;--fg:#e8e6e3;--dim:#8b8781;--line:#26262a;--go:#3fb950;--kill:#f85149;--cust:#d29922}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 ui-sans-serif,-apple-system,system-ui,sans-serif}
+h1{font-size:15px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);font-weight:600;margin:0}
+header.top{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;gap:20px;align-items:baseline;flex-wrap:wrap;position:sticky;top:0;background:var(--bg);z-index:5}
+.k{font-size:12px;color:var(--dim)}.k b{color:var(--fg);font-size:16px}
+section{padding:20px 24px}h2{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--dim);margin:0 0 14px}
+.grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(var(--w,320px),1fr))}
+.c{border:1px solid var(--line);border-radius:8px;padding:13px 15px;background:#141416;border-left-width:3px}
+.c.ready{border-left-color:var(--go)}.c.delete{border-left-color:var(--kill)}.c.cust{border-left-color:var(--cust)}
+.c header{display:flex;justify-content:space-between;gap:8px;align-items:baseline;margin-bottom:6px}
+.v{font-weight:650}.acct{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);border:1px solid var(--line);border-radius:99px;padding:1px 7px}
+.s{margin:0 0 6px;font-size:13px}.to{margin:0 0 4px;font-size:12px;color:var(--dim);word-break:break-all}
+.gap{margin:0;font-size:12px;color:var(--go)}.why{margin:0;font-size:12px;color:var(--kill)}
+.c.cust .why{color:var(--cust)}
+.c footer{display:flex;justify-content:space-between;gap:10px;margin-top:9px;font-size:11px;align-items:center}
+time{color:var(--dim)}a{color:#58a6ff}code{display:block;margin-top:6px;font-size:10px;color:#4a4a50}
+.controls{margin-left:auto;display:flex;gap:14px;align-items:center;font-size:12px;color:var(--dim)}
+input[type=range]{width:130px}
+</style>
+<header class=top><h1>Unsent drafts</h1>
+<span class=k><b>${d.ready.length}</b> ready to send</span>
+<span class=k><b>${d.delete.length}</b> to delete</span>
+<span class=k style="color:var(--cust)"><b>${d.delete.filter(x=>x.customer).length}</b> stale customer replies</span>
+<span class=controls>density <input type=range min=240 max=520 value=320 id=dn></span></header>
+<section><h2 style="color:var(--go)">▸ Ready to send — ${d.ready.length}</h2>
+<div class="grid" id=g1>${d.ready.map(x=>card(x,'ready')).join('')}</div></section>
+<section><h2 style="color:var(--kill)">▸ Delete — ${d.delete.length}</h2>
+<div class="grid" id=g2>${d.delete.map(x=>card(x,'delete')).join('')}</div></section>
+<p style="padding:0 24px 30px;color:var(--dim);font-size:12px">${esc(d.note)} · snapshot ${esc(d.generated)}</p>
+<script>
+const dn=document.getElementById('dn');const set=v=>{document.querySelectorAll('.grid').forEach(g=>g.style.setProperty('--w',v+'px'));try{localStorage.setItem('dv-w',v)}catch(e){}};
+try{const s=localStorage.getItem('dv-w');if(s){dn.value=s;set(s)}}catch(e){}
+dn.addEventListener('input',e=>set(e.target.value));
+</script>`;}
+http.createServer((req,res)=>{
+ const a=(req.headers.authorization||'').split(' ')[1]||'';
+ if(Buffer.from(a,'base64').toString()!==USER+':'+PASS){res.writeHead(401,{'WWW-Authenticate':'Basic realm="drafts"'});return res.end('auth');}
+ let d;try{d=JSON.parse(fs.readFileSync(DATA,'utf8'))}catch(e){res.writeHead(500);return res.end('no snapshot: '+e.message)}
+ res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});res.end(page(d));
+}).listen(0,'127.0.0.1',function(){console.log('PORT='+this.address().port)});
(oldest)
·
back to Draft Viewer
·
draft-viewer: make the board honest — verify triage against 20c1dc8 →