← back to Draft Viewer
restore.mjs
67 lines
#!/usr/bin/env node
// restore.mjs — TK-11231
// Replays a snapshotted draft body back into Gmail as a NEW draft.
//
// This is what makes the snapshot a backup rather than a claim. Same doctrine as
// dw-restore-rehearsal: a dump nobody has replayed is not proven restorable.
// It NEVER sends — Gmail drafts.create only. Composing a draft cannot deliver mail.
//
// node restore.mjs <snapshot.json> # dry run, prints what it would do
// node restore.mjs <snapshot.json> --apply # recreate the draft for real
// node restore.mjs <snapshot.json> --apply --test-to <addr>
// redirect the recipient — used to REHEARSE the restore without creating a
// second real draft addressed at a live vendor.
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith('--'));
const APPLY = args.includes('--apply');
const testTo = args.includes('--test-to') ? args[args.indexOf('--test-to') + 1] : null;
if (!file) { console.error('usage: node restore.mjs <snapshot.json> [--apply] [--test-to addr]'); process.exit(1); }
function george() {
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'));
const env = cfg?.mcpServers?.george?.env || {};
return { url: (env.GEORGE_URL || 'http://127.0.0.1:9850').replace(/\/$/, ''),
headers: { Authorization: 'Basic ' + env.GEORGE_BASIC_AUTH, 'content-type': 'application/json' } };
}
const snap = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8'));
const m = snap.message, acct = snap.board.account;
const to = testTo || m.to;
// George stamps a "From job: …" provenance banner onto every draft it creates.
// The snapshot captured a draft that already carries one, so restoring as-is
// would prepend a SECOND banner and each further restore another — the restore
// would not be idempotent. Strip a leading banner so a snapshot can be replayed
// any number of times and still match the original.
const stripBanner = (s) => s.replace(
// the whole leading <div …>…From job:…</div> block, not just the text — the
// banner is a styled div wrapping <strong>From job:</strong>, so matching on
// the bare phrase leaves the wrapper behind and strips nothing.
/^\s*<div\b[^>]*>(?:(?!<\/div>)[\s\S])*?From job:(?:(?!<\/div>)[\s\S])*?<\/div>\s*/i, '');
const body = stripBanner(m.body || '');
if (!body.trim()) { console.error('REFUSING: snapshot has an empty body — nothing to restore.'); process.exit(2); }
console.log(`restore ${APPLY ? '(APPLY)' : '(dry run)'}`);
console.log(` from : ${file}`);
console.log(` account : ${acct}`);
console.log(` to : ${to}${testTo ? ' [REDIRECTED for rehearsal]' : ''}`);
console.log(` subject : ${m.subject}`);
console.log(` body : ${body.length} chars`);
if (!APPLY) { console.log('\ndry run — nothing written. add --apply to recreate.'); process.exit(0); }
const g = george();
// acknowledge_existing: this is a deliberate restore of a known draft, so the
// duplicate-refusal added earlier today must not block a recovery.
const res = await fetch(g.url + '/api/drafts', {
method: 'POST', headers: g.headers,
body: JSON.stringify({ account: acct, to, subject: m.subject, body, acknowledge_existing: true }),
});
const out = await res.json().catch(() => ({}));
if (!res.ok) { console.error('restore FAILED:', res.status, JSON.stringify(out).slice(0, 400)); process.exit(3); }
console.log('\nrestored →', JSON.stringify({ draftId: out.draftId || out.id, messageId: out.messageId || out.message?.id }));
console.log('This created a DRAFT. It was not sent.');