← 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.');