← back to George Gmail

server.js

2459 lines

/**
 * George the Gmail Agent — Port 9889
 * Secure email access for DW-Agents ecosystem
 * Auth: admin / (set via GEORGE_BASIC_AUTH_PASS env var)
 */
const express = require('express');
const helmet = require('helmet');
const path = require('path');
const fs = require('fs');
const { google } = require('googleapis');
const nodemailer = require('nodemailer');

const PORT = process.env.PORT || 9850;
const AGENT_NAME = 'George';
const AGENT_CODENAME = 'Gmail';
const AGENT_COLOR = '#EA4335';

// ─── Gmail OAuth Credentials (loaded from DW-MCP .env) ───
// Portable across Mac2 (~/Projects/...) and Kamatera (/root/Projects/...).
// Try $HOME first, fall back to the legacy Kamatera path so we don't break
// the Kamatera deploy. Source of this fix: 2026-05-12 — on Mac2 the
// hardcoded /root/ path silently fails the readFileSync (caught by try/catch
// upstream) and George boots without INFO_REFRESH_TOKEN → "Info@ not
// configured" even though the token IS in ~/Projects/.../.env.
const ENV_PATH = (() => {
  const tryPaths = [
    process.env.DW_MCP_ENV,
    require('path').join(process.env.HOME || '', 'Projects/Designer-Wallcoverings/DW-MCP/.env'),
    '/root/Projects/Designer-Wallcoverings/DW-MCP/.env',
  ].filter(Boolean);
  for (const p of tryPaths) {
    try { require('fs').accessSync(p, require('fs').constants.R_OK); return p; } catch {}
  }
  return tryPaths[tryPaths.length - 1];
})();
const creds = {};
try {
  const envContent = fs.readFileSync(ENV_PATH, 'utf8');
  envContent.split('\n').forEach(line => {
    const match = line.match(/^([^#=]+)=(.+)/);
    if (match) creds[match[1].trim()] = match[2].trim();
  });
  console.log(`[${AGENT_NAME}] Loaded credentials from ${ENV_PATH}`);
} catch (e) {
  console.error(`[${AGENT_NAME}] Failed to load env: ${e.message}`);
}

const GMAIL_CLIENT_ID = creds.GMAIL_CLIENT_ID || process.env.GMAIL_CLIENT_ID;
const GMAIL_CLIENT_SECRET = creds.GMAIL_CLIENT_SECRET || process.env.GMAIL_CLIENT_SECRET;
const GMAIL_REDIRECT_URI = 'http://localhost:9850/oauth2callback';
const GMAIL_REFRESH_TOKEN = creds.GMAIL_REFRESH_TOKEN || process.env.GMAIL_REFRESH_TOKEN;

// ─── Info@ Account Credentials ───
const INFO_REDIRECT_URI = 'http://localhost:9850/oauth2callback';
const INFO_REFRESH_TOKEN = creds.INFO_REFRESH_TOKEN || process.env.INFO_REFRESH_TOKEN;

// ─── Steve Personal (steveabramsdesigns@gmail.com) Credentials ───
// Reuses GMAIL_CLIENT_ID/SECRET (see PLAN-GSUITE-EXPANSION.md Q1=a).
const PERSONAL_REDIRECT_URI = 'http://localhost:9850/oauth2callback';
const PERSONAL_REFRESH_TOKEN = creds.PERSONAL_REFRESH_TOKEN || process.env.PERSONAL_REFRESH_TOKEN;

// ─── Agent Abrams (theagentabrams@gmail.com) Credentials ───
// Reuses GMAIL_CLIENT_ID/SECRET. Added 2026-05-18 per Steve.
const AGENTABRAMS_REDIRECT_URI = 'http://localhost:9850/oauth2callback';
const AGENTABRAMS_REFRESH_TOKEN = creds.AGENTABRAMS_REFRESH_TOKEN || process.env.AGENTABRAMS_REFRESH_TOKEN;
const AGENTABRAMS_LOGIN_HINT = creds.AGENTABRAMS_LOGIN_HINT || process.env.AGENTABRAMS_LOGIN_HINT || 'theagentabrams@gmail.com';

// ─── OAuth2 Scopes ───
// FULL_WORKSPACE_SCOPES = unified scope list for all 3 accounts.
// Steve approved including Forms (Q3=y) so we don't have to re-OAuth later.
const FULL_WORKSPACE_SCOPES = [
  'https://www.googleapis.com/auth/gmail.modify',
  'https://www.googleapis.com/auth/gmail.settings.sharing',  // sendAs alias create/update
  'https://www.googleapis.com/auth/gmail.settings.basic',    // filters / labels
  'https://www.googleapis.com/auth/tasks',
  'https://www.googleapis.com/auth/drive',
  'https://www.googleapis.com/auth/documents',
  'https://www.googleapis.com/auth/spreadsheets',
  'https://www.googleapis.com/auth/presentations',
  'https://www.googleapis.com/auth/calendar',
  'https://www.googleapis.com/auth/forms.body',
  'https://www.googleapis.com/auth/forms.responses.readonly',
];
// Kept as aliases so older references don't break mid-rollout.
const SCOPES = FULL_WORKSPACE_SCOPES;
const INFO_SCOPES = FULL_WORKSPACE_SCOPES;
const PERSONAL_SCOPES = FULL_WORKSPACE_SCOPES;

// ─── Service-account / domain-wide-delegation (DORMANT until configured) ──────
// The Testing-mode OAuth app expires refresh tokens ~weekly (see PLAN-OAUTH-DURABILITY.md).
// For Workspace accounts we can switch to a service account with domain-wide delegation:
// the SA impersonates the user and mints tokens per request — no refresh token, no weekly
// expiry, no consent screen. FULLY DORMANT by default: with no GOOGLE_SA_KEYFILE this whole
// path is skipped and every account keeps using its existing refresh-token client byte-for-byte.
// To arm (AFTER Steve authorizes the SA client ID for the scopes in Workspace Admin →
// Security → API controls → Domain-wide delegation), set in the DW-MCP .env:
//   GOOGLE_SA_KEYFILE=/abs/path/sa-key.json     (route via the secrets skill)
//   GEORGE_SA_ACCOUNTS=steve-office,info        (Workspace accounts ONLY)
// Consumer @gmail accounts (steve-personal, agentabrams) CANNOT use DWD and are ignored
// even if listed — they stay on the refresh-token path + the 6-day token-age WARN.
const SA_KEYFILE = creds.GOOGLE_SA_KEYFILE || process.env.GOOGLE_SA_KEYFILE || creds.GEORGE_SA_KEYFILE || process.env.GEORGE_SA_KEYFILE || '';
const SA_ACCOUNTS = String(creds.GEORGE_SA_ACCOUNTS || process.env.GEORGE_SA_ACCOUNTS || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
// Only these Workspace accounts have an impersonable domain address; consumer gmail keys are
// intentionally absent so they can never be SA-backed.
const SA_IMPERSONATE = { 'steve-office': 'steve@designerwallcoverings.com', 'info': 'info@designerwallcoverings.com' };
const SA_KEY_OK = !!SA_KEYFILE && (() => { try { fs.accessSync(SA_KEYFILE, fs.constants.R_OK); return true; } catch { return false; } })();
if (SA_ACCOUNTS.length && !SA_KEY_OK) console.warn(`[${AGENT_NAME}] SA/DWD requested for [${SA_ACCOUNTS}] but key unreadable at "${SA_KEYFILE}" — staying on refresh-token path`);

// Domain-wide-delegation JWT auth for `key`, or null if not SA-eligible/armed.
function saAuthFor(key) {
  if (!SA_KEY_OK || !SA_ACCOUNTS.includes(key)) return null;
  const subject = SA_IMPERSONATE[key];
  if (!subject) { console.warn(`[${AGENT_NAME}] SA/DWD skip "${key}" — no impersonable Workspace address (consumer gmail can't use DWD)`); return null; }
  try {
    return new google.auth.JWT({ keyFile: SA_KEYFILE, scopes: FULL_WORKSPACE_SCOPES, subject });
  } catch (e) { console.error(`[${AGENT_NAME}] SA/DWD auth build failed for "${key}": ${e.message}`); return null; }
}

// Steve@ account (primary)
let oauth2Client;
let gmail;
let tasksApi;
try {
  oauth2Client = new google.auth.OAuth2(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REDIRECT_URI);
  oauth2Client.setCredentials({ refresh_token: GMAIL_REFRESH_TOKEN });
  gmail = google.gmail({ version: 'v1', auth: oauth2Client });
  tasksApi = google.tasks({ version: 'v1', auth: oauth2Client });
  console.log(`[${AGENT_NAME}] Gmail + Tasks API client initialized (steve@)`);
} catch (e) {
  console.error(`[${AGENT_NAME}] API client error: ${e.message}`);
}

// Info@ account (secondary — now full Workspace)
let infoOauth2Client;
let infoGmail;
let infoTasksApi;
let infoDrive;
let infoDocs;
let infoSheets;
let infoSlides;
let infoCalendar;
let infoForms;
try {
  infoOauth2Client = new google.auth.OAuth2(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, INFO_REDIRECT_URI);
  if (INFO_REFRESH_TOKEN) {
    infoOauth2Client.setCredentials({ refresh_token: INFO_REFRESH_TOKEN });
    infoGmail = google.gmail({ version: 'v1', auth: infoOauth2Client });
    infoTasksApi = google.tasks({ version: 'v1', auth: infoOauth2Client });
    infoDrive = google.drive({ version: 'v3', auth: infoOauth2Client });
    infoDocs = google.docs({ version: 'v1', auth: infoOauth2Client });
    infoSheets = google.sheets({ version: 'v4', auth: infoOauth2Client });
    infoSlides = google.slides({ version: 'v1', auth: infoOauth2Client });
    infoCalendar = google.calendar({ version: 'v3', auth: infoOauth2Client });
    infoForms = google.forms({ version: 'v1', auth: infoOauth2Client });
    console.log(`[${AGENT_NAME}] Info@ account initialized (full Workspace)`);
  } else {
    console.log(`[${AGENT_NAME}] Info@ not configured — visit /auth/info to authorize`);
  }
} catch (e) {
  console.error(`[${AGENT_NAME}] Info@ client error: ${e.message}`);
}

// ─── Steve@dw account — upgrade to full Workspace ───
let docs, sheets, slides, drive, calendar, forms;
try {
  if (oauth2Client && GMAIL_REFRESH_TOKEN) {
    docs = google.docs({ version: 'v1', auth: oauth2Client });
    sheets = google.sheets({ version: 'v4', auth: oauth2Client });
    slides = google.slides({ version: 'v1', auth: oauth2Client });
    drive = google.drive({ version: 'v3', auth: oauth2Client });
    calendar = google.calendar({ version: 'v3', auth: oauth2Client });
    forms = google.forms({ version: 'v1', auth: oauth2Client });
    console.log(`[${AGENT_NAME}] Steve Office account upgraded to full Workspace`);
  }
} catch (e) {
  console.error(`[${AGENT_NAME}] Steve Office Workspace upgrade error: ${e.message}`);
}

// ─── Steve Personal (steveabramsdesigns@gmail.com) account ───
let personalOauth2Client;
let personalGmail, personalTasksApi, personalDrive, personalDocs, personalSheets, personalSlides, personalCalendar, personalForms;
try {
  personalOauth2Client = new google.auth.OAuth2(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, PERSONAL_REDIRECT_URI);
  if (PERSONAL_REFRESH_TOKEN) {
    personalOauth2Client.setCredentials({ refresh_token: PERSONAL_REFRESH_TOKEN });
    personalGmail = google.gmail({ version: 'v1', auth: personalOauth2Client });
    personalTasksApi = google.tasks({ version: 'v1', auth: personalOauth2Client });
    personalDrive = google.drive({ version: 'v3', auth: personalOauth2Client });
    personalDocs = google.docs({ version: 'v1', auth: personalOauth2Client });
    personalSheets = google.sheets({ version: 'v4', auth: personalOauth2Client });
    personalSlides = google.slides({ version: 'v1', auth: personalOauth2Client });
    personalCalendar = google.calendar({ version: 'v3', auth: personalOauth2Client });
    personalForms = google.forms({ version: 'v1', auth: personalOauth2Client });
    console.log(`[${AGENT_NAME}] Steve Personal account initialized (full Workspace)`);
  } else {
    console.log(`[${AGENT_NAME}] Steve Personal not configured — visit /auth/steve-personal to authorize`);
  }
} catch (e) {
  console.error(`[${AGENT_NAME}] Steve Personal client error: ${e.message}`);
}

// ─── Agent Abrams (theagentabrams@gmail.com) account ───
let agentabramsOauth2Client;
let agentabramsGmail, agentabramsTasksApi, agentabramsDrive, agentabramsDocs, agentabramsSheets, agentabramsSlides, agentabramsCalendar, agentabramsForms;
try {
  agentabramsOauth2Client = new google.auth.OAuth2(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, AGENTABRAMS_REDIRECT_URI);
  if (AGENTABRAMS_REFRESH_TOKEN) {
    agentabramsOauth2Client.setCredentials({ refresh_token: AGENTABRAMS_REFRESH_TOKEN });
    agentabramsGmail    = google.gmail   ({ version: 'v1', auth: agentabramsOauth2Client });
    agentabramsTasksApi = google.tasks   ({ version: 'v1', auth: agentabramsOauth2Client });
    agentabramsDrive    = google.drive   ({ version: 'v3', auth: agentabramsOauth2Client });
    agentabramsDocs     = google.docs    ({ version: 'v1', auth: agentabramsOauth2Client });
    agentabramsSheets   = google.sheets  ({ version: 'v4', auth: agentabramsOauth2Client });
    agentabramsSlides   = google.slides  ({ version: 'v1', auth: agentabramsOauth2Client });
    agentabramsCalendar = google.calendar({ version: 'v3', auth: agentabramsOauth2Client });
    agentabramsForms    = google.forms   ({ version: 'v1', auth: agentabramsOauth2Client });
    console.log(`[${AGENT_NAME}] Agent Abrams account initialized (full Workspace)`);
  } else {
    console.log(`[${AGENT_NAME}] Agent Abrams not configured — visit /auth/agentabrams to authorize`);
  }
} catch (e) {
  console.error(`[${AGENT_NAME}] Agent Abrams client error: ${e.message}`);
}

// ─── SA/DWD override (DORMANT unless GOOGLE_SA_KEYFILE + GEORGE_SA_ACCOUNTS set) ─
// For each SA-armed Workspace account, rebuild its per-service API clients on the
// domain-wide-delegation JWT auth, replacing the refresh-token clients. Additive:
// when SA is off, saAuthFor() returns null and this block is a no-op, leaving the
// original refresh-token clients exactly as-is. resolveAccount() reads these
// module-level vars per request, so it transparently picks up the SA clients.
try {
  const svc = (auth) => ({
    gmail:    google.gmail   ({ version: 'v1', auth }),
    tasks:    google.tasks   ({ version: 'v1', auth }),
    drive:    google.drive   ({ version: 'v3', auth }),
    docs:     google.docs    ({ version: 'v1', auth }),
    sheets:   google.sheets  ({ version: 'v4', auth }),
    slides:   google.slides  ({ version: 'v1', auth }),
    calendar: google.calendar({ version: 'v3', auth }),
    forms:    google.forms   ({ version: 'v1', auth }),
  });
  const saSteve = saAuthFor('steve-office');
  if (saSteve) {
    const s = svc(saSteve);
    oauth2Client = saSteve; gmail = s.gmail; tasksApi = s.tasks;
    drive = s.drive; docs = s.docs; sheets = s.sheets; slides = s.slides; calendar = s.calendar; forms = s.forms;
    console.log(`[${AGENT_NAME}] steve-office → SA/DWD (impersonating ${SA_IMPERSONATE['steve-office']}) — refresh-token path bypassed`);
  }
  const saInfo = saAuthFor('info');
  if (saInfo) {
    const s = svc(saInfo);
    infoOauth2Client = saInfo; infoGmail = s.gmail; infoTasksApi = s.tasks;
    infoDrive = s.drive; infoDocs = s.docs; infoSheets = s.sheets; infoSlides = s.slides; infoCalendar = s.calendar; infoForms = s.forms;
    console.log(`[${AGENT_NAME}] info → SA/DWD (impersonating ${SA_IMPERSONATE['info']}) — refresh-token path bypassed`);
  }
} catch (e) {
  console.error(`[${AGENT_NAME}] SA/DWD override error (staying on refresh-token path): ${e.message}`);
}

// ─── Account resolver for per-service API endpoints ───
// account query/body param: "steve-office" (default), "info", "steve-personal", "agentabrams"
function resolveAccount(req) {
  const key = (req.query.account || req.body?.account || 'steve-office').toLowerCase();
  const map = {
    'steve-office':   { label: 'steve@designerwallcoverings.com', oauth: oauth2Client, gmail, tasks: tasksApi, drive, docs, sheets, slides, calendar, forms, refreshToken: GMAIL_REFRESH_TOKEN },
    'info':           { label: 'info@designerwallcoverings.com',  oauth: infoOauth2Client, gmail: infoGmail, tasks: infoTasksApi, drive: infoDrive, docs: infoDocs, sheets: infoSheets, slides: infoSlides, calendar: infoCalendar, forms: infoForms, refreshToken: INFO_REFRESH_TOKEN },
    'steve-personal': { label: 'steveabramsdesigns@gmail.com',    oauth: personalOauth2Client, gmail: personalGmail, tasks: personalTasksApi, drive: personalDrive, docs: personalDocs, sheets: personalSheets, slides: personalSlides, calendar: personalCalendar, forms: personalForms, refreshToken: PERSONAL_REFRESH_TOKEN },
    'agentabrams':    { label: AGENTABRAMS_LOGIN_HINT,            oauth: agentabramsOauth2Client, gmail: agentabramsGmail, tasks: agentabramsTasksApi, drive: agentabramsDrive, docs: agentabramsDocs, sheets: agentabramsSheets, slides: agentabramsSlides, calendar: agentabramsCalendar, forms: agentabramsForms, refreshToken: AGENTABRAMS_REFRESH_TOKEN },
  };
  return { key, ...(map[key] || {}) };
}

// Audit log for every Workspace write operation
const AUDIT_LOG = '/root/DW-Agents/logs/george-workspace-audit.log';
function audit(account, action, details) {
  try {
    const line = `${new Date().toISOString()}\t${account}\t${action}\t${JSON.stringify(details)}\n`;
    fs.appendFileSync(AUDIT_LOG, line);
  } catch {}
}

// ─── Express App ───
const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json({ limit: '50mb' }));

// ─── Basic Auth credential resolution (2026-05-24 migration PR-1) ───
// Reads GEORGE_BASIC_AUTH=user:pass from env if present; falls back to the
// historical hardcoded value so the ~20 caller scripts in /root/DW-Agents/
// keep working unchanged. Future PRs in the migration plan documented at
// /root/DW-Agents/KNOWN-ISSUE-george-auth.md will switch callers off the
// hardcoded value, then rotate, then drop the fallback.
const _GEORGE_AUTH_ENV =
  (typeof creds !== "undefined" && creds.GEORGE_BASIC_AUTH) ||
  process.env.GEORGE_BASIC_AUTH ||
  "";
const _GEORGE_AUTH_SOURCE = _GEORGE_AUTH_ENV ? "env" : "hardcoded-fallback";
const [_GEORGE_USER, _GEORGE_PASS] = _GEORGE_AUTH_ENV.includes(":")
  ? _GEORGE_AUTH_ENV.split(":")
  : ["admin", (process.env.GEORGE_BASIC_AUTH_PASS || "")];
console.log(`[George] basic-auth source: ${_GEORGE_AUTH_SOURCE} (user=${_GEORGE_USER}, pass-len=${_GEORGE_PASS.length})`);
const crypto = require("crypto");
function _ctEq(a, b) {
  const ab = Buffer.from(String(a)); const bb = Buffer.from(String(b));
  if (ab.length !== bb.length) { crypto.timingSafeEqual(ab, ab); return false; }
  return crypto.timingSafeEqual(ab, bb);
}

// ─── Basic Auth (skip health + OAuth callbacks) ───
app.use((req, res, next) => {
  const publicPaths = ['/health', '/auth', '/auth/info', '/auth/steve-office', '/auth/steve-personal', '/auth/agentabrams', '/oauth2callback'];
  if (publicPaths.includes(req.path)) return next();
  const auth = req.headers.authorization;
  if (!auth || !auth.startsWith('Basic ')) {
    res.setHeader('WWW-Authenticate', 'Basic realm="George Gmail Agent"');
    return res.status(401).json({ error: 'Authentication required' });
  }
  // Fail closed: an unset/empty credential must never mean "no auth" — this
  // server binds all interfaces and fronts live Gmail accounts (2026-07-13,
  // found running with pass-len=0 after a half-applied rotation).
  if (!_GEORGE_PASS) return res.status(503).json({ error: 'Auth not configured on server' });
  const decoded = Buffer.from(auth.slice(6), 'base64').toString();
  const [user, pass] = decoded.split(':');
  if (_ctEq(user, _GEORGE_USER) && _ctEq(pass, _GEORGE_PASS)) return next();
  res.setHeader('WWW-Authenticate', 'Basic realm="George Gmail Agent"');
  res.status(401).json({ error: 'Invalid credentials' });
});

// ─── Static-snapshot 404 guard ───
// Defense-in-depth: never serve *.bak / *.pre-* / *.orig / *.rej / *~ from
// the static root even if a stray snapshot lands in public/. Runs before
// express.static so the snapshot file is invisible regardless of disk state.
app.use((req, res, next) => {
  if (/\.(bak|orig|rej|swp)(\.|$)|\.bak-|\.pre-|~$/.test(req.path)) {
    return res.status(404).send('Not found');
  }
  next();
});

app.use(express.static(path.join(__dirname, 'public')));

// ─── OAuth Re-Authorization Flow ───
const OAUTH_REDIRECT = 'http://localhost:9850/oauth2callback';

app.get('/auth', (req, res) => {
  // Honor ?account= — dispatch to the matching dedicated re-auth route so
  // each account gets its own oauth client, scopes, login_hint, and state.
  // Keys match resolveAccount(): steve-office (default), info, steve-personal, agentabrams.
  const acct = (req.query.account || 'steve-office').toLowerCase();
  const routes = {
    'info':           '/auth/info',
    'steve-personal': '/auth/steve-personal',
    'agentabrams':    '/auth/agentabrams',
  };
  if (routes[acct]) return res.redirect(routes[acct]);
  // steve-office (default, or unknown account key) — full Workspace re-auth for steve@
  if (!oauth2Client) return res.status(500).send('OAuth client not configured');
  const url = oauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: FULL_WORKSPACE_SCOPES,
    prompt: 'consent',
    redirect_uri: OAUTH_REDIRECT,
    login_hint: 'steve@designerwallcoverings.com',
  });
  res.redirect(url);
});

// Alias: /auth/steve-office — same as /auth, clearer name
app.get('/auth/steve-office', (req, res) => res.redirect('/auth'));

app.get('/oauth2callback', async (req, res) => {
  try {
    const { code, state } = req.query;
    if (!code) return res.status(400).send('No authorization code received');

    // Route to agentabrams handler if state=agentabrams
    if (state === 'agentabrams') {
      const { tokens } = await agentabramsOauth2Client.getToken({ code, redirect_uri: AGENTABRAMS_REDIRECT_URI });
      agentabramsOauth2Client.setCredentials(tokens);
      agentabramsGmail    = google.gmail   ({ version: 'v1', auth: agentabramsOauth2Client });
      agentabramsTasksApi = google.tasks   ({ version: 'v1', auth: agentabramsOauth2Client });
      agentabramsDrive    = google.drive   ({ version: 'v3', auth: agentabramsOauth2Client });
      agentabramsDocs     = google.docs    ({ version: 'v1', auth: agentabramsOauth2Client });
      agentabramsSheets   = google.sheets  ({ version: 'v4', auth: agentabramsOauth2Client });
      agentabramsSlides   = google.slides  ({ version: 'v1', auth: agentabramsOauth2Client });
      agentabramsCalendar = google.calendar({ version: 'v3', auth: agentabramsOauth2Client });
      agentabramsForms    = google.forms   ({ version: 'v1', auth: agentabramsOauth2Client });

      let msg = '<html><body style="font-family:sans-serif;background:#0f1117;color:#e4e4e7;padding:40px">';
      msg += '<h2 style="color:#4ade80">Agent Abrams Authorized! (Full Google Workspace)</h2>';
      if (tokens.refresh_token) {
        msg += `<p><strong>New Agent Abrams Refresh Token:</strong></p><code style="background:#1a1d27;padding:10px;display:block;word-break:break-all;border-radius:8px">${tokens.refresh_token}</code>`;
        console.log(`[${AGENT_NAME}] Agent Abrams refresh token obtained: ${tokens.refresh_token.substring(0, 20)}...`);
        try {
          let envContent = fs.readFileSync(ENV_PATH, 'utf8');
          if (envContent.includes('AGENTABRAMS_REFRESH_TOKEN=')) {
            envContent = envContent.replace(/AGENTABRAMS_REFRESH_TOKEN=.*/, `AGENTABRAMS_REFRESH_TOKEN=${tokens.refresh_token}`);
          } else {
            envContent += `\n# Agent Abrams (theagentabrams@gmail.com) OAuth Token\nAGENTABRAMS_REFRESH_TOKEN=${tokens.refresh_token}\n`;
          }
          fs.writeFileSync(ENV_PATH, envContent);
          msg += '<p style="color:#4ade80">Token auto-saved to central .env!</p>';
          audit('agentabrams', 'oauth-authorized', { scopes: FULL_WORKSPACE_SCOPES.length });
        } catch (e) {
          msg += `<p style="color:#f87171">Could not auto-save: ${e.message}</p>`;
        }
      }
      try {
        const profile = await agentabramsGmail.users.getProfile({ userId: 'me' });
        msg += `<p style="color:#60a5fa">Connected as: <strong>${profile.data.emailAddress}</strong></p>`;
      } catch {}
      msg += '<p style="color:#a3a3a3">You can close this tab. Restart george-gmail to load the new token persistently.</p>';
      msg += '<p><a href="/" style="color:#60a5fa">Go to Dashboard</a></p></body></html>';
      return res.send(msg);
    }

    // Route to steve-personal handler if state=personal
    if (state === 'personal') {
      const { tokens } = await personalOauth2Client.getToken({ code, redirect_uri: PERSONAL_REDIRECT_URI });
      personalOauth2Client.setCredentials(tokens);
      personalGmail = google.gmail({ version: 'v1', auth: personalOauth2Client });
      personalTasksApi = google.tasks({ version: 'v1', auth: personalOauth2Client });
      personalDrive = google.drive({ version: 'v3', auth: personalOauth2Client });
      personalDocs = google.docs({ version: 'v1', auth: personalOauth2Client });
      personalSheets = google.sheets({ version: 'v4', auth: personalOauth2Client });
      personalSlides = google.slides({ version: 'v1', auth: personalOauth2Client });
      personalCalendar = google.calendar({ version: 'v3', auth: personalOauth2Client });
      personalForms = google.forms({ version: 'v1', auth: personalOauth2Client });

      let msg = '<html><body style="font-family:sans-serif;background:#0f1117;color:#e4e4e7;padding:40px">';
      msg += '<h2 style="color:#4ade80">Steve Personal Authorized! (Full Google Workspace)</h2>';
      if (tokens.refresh_token) {
        msg += `<p><strong>New Personal Refresh Token:</strong></p><code style="background:#1a1d27;padding:10px;display:block;word-break:break-all;border-radius:8px">${tokens.refresh_token}</code>`;
        console.log(`[${AGENT_NAME}] Personal refresh token obtained: ${tokens.refresh_token.substring(0, 20)}...`);
        try {
          let envContent = fs.readFileSync(ENV_PATH, 'utf8');
          if (envContent.includes('PERSONAL_REFRESH_TOKEN=')) {
            envContent = envContent.replace(/PERSONAL_REFRESH_TOKEN=.*/, `PERSONAL_REFRESH_TOKEN=${tokens.refresh_token}`);
          } else {
            envContent += `\n# Steve Personal (steveabramsdesigns@gmail.com) OAuth Token\nPERSONAL_REFRESH_TOKEN=${tokens.refresh_token}\n`;
          }
          fs.writeFileSync(ENV_PATH, envContent);
          msg += '<p style="color:#4ade80">Token auto-saved to central .env!</p>';
          audit('steve-personal', 'oauth-authorized', { scopes: PERSONAL_SCOPES.length });
        } catch (e) {
          msg += `<p style="color:#f87171">Could not auto-save: ${e.message}</p>`;
        }
      }
      try {
        const profile = await personalGmail.users.getProfile({ userId: 'me' });
        msg += `<p style="color:#60a5fa">Connected as: <strong>${profile.data.emailAddress}</strong></p>`;
      } catch {}
      msg += '<p><a href="/" style="color:#60a5fa">Go to Dashboard</a></p></body></html>';
      return res.send(msg);
    }

    // Route to info@ handler if state=info
    if (state === 'info') {
      const { tokens } = await infoOauth2Client.getToken({ code, redirect_uri: INFO_REDIRECT_URI });
      infoOauth2Client.setCredentials(tokens);
      infoGmail = google.gmail({ version: 'v1', auth: infoOauth2Client });
      infoTasksApi = google.tasks({ version: 'v1', auth: infoOauth2Client });
      infoDrive = google.drive({ version: 'v3', auth: infoOauth2Client });

      let msg = '<html><body style="font-family:sans-serif;background:#0f1117;color:#e4e4e7;padding:40px">';
      msg += '<h2 style="color:#4ade80">Info@ Authorization Successful! (Gmail + Tasks + Drive)</h2>';
      if (tokens.refresh_token) {
        msg += `<p><strong>New Info@ Refresh Token:</strong></p><code style="background:#1a1d27;padding:10px;display:block;word-break:break-all;border-radius:8px">${tokens.refresh_token}</code>`;
        msg += '<p style="color:#f59e0b">Save this token to your .env file as INFO_REFRESH_TOKEN</p>';
        console.log(`[${AGENT_NAME}] Info@ refresh token obtained: ${tokens.refresh_token.substring(0, 20)}...`);
        try {
          let envContent = fs.readFileSync(ENV_PATH, 'utf8');
          if (envContent.includes('INFO_REFRESH_TOKEN=')) {
            envContent = envContent.replace(/INFO_REFRESH_TOKEN=.*/, `INFO_REFRESH_TOKEN=${tokens.refresh_token}`);
          } else {
            envContent += `\n# Info@ Account OAuth Token\nINFO_REFRESH_TOKEN=${tokens.refresh_token}\n`;
          }
          fs.writeFileSync(ENV_PATH, envContent);
          msg += '<p style="color:#4ade80">Token auto-saved to .env!</p>';
        } catch (e) {
          msg += `<p style="color:#f87171">Could not auto-save: ${e.message}</p>`;
        }
      }
      try {
        const profile = await infoGmail.users.getProfile({ userId: 'me' });
        msg += `<p style="color:#60a5fa">Connected as: <strong>${profile.data.emailAddress}</strong></p>`;
      } catch {}
      msg += '<p><a href="/" style="color:#60a5fa">Go to Dashboard</a></p></body></html>';
      return res.send(msg);
    }

    // Default: steve@ account (now full Workspace)
    const { tokens } = await oauth2Client.getToken({ code, redirect_uri: OAUTH_REDIRECT });
    oauth2Client.setCredentials(tokens);
    gmail = google.gmail({ version: 'v1', auth: oauth2Client });
    tasksApi = google.tasks({ version: 'v1', auth: oauth2Client });
    drive = google.drive({ version: 'v3', auth: oauth2Client });
    docs = google.docs({ version: 'v1', auth: oauth2Client });
    sheets = google.sheets({ version: 'v4', auth: oauth2Client });
    slides = google.slides({ version: 'v1', auth: oauth2Client });
    calendar = google.calendar({ version: 'v3', auth: oauth2Client });
    forms = google.forms({ version: 'v1', auth: oauth2Client });

    let msg = '<html><body style="font-family:sans-serif;background:#0f1117;color:#e4e4e7;padding:40px">';
    msg += '<h2 style="color:#4ade80">Authorization Successful! (Full Google Workspace)</h2>';
    if (tokens.refresh_token) {
      msg += `<p><strong>New Refresh Token:</strong></p><code style="background:#1a1d27;padding:10px;display:block;word-break:break-all;border-radius:8px">${tokens.refresh_token}</code>`;
      console.log(`[${AGENT_NAME}] New refresh token obtained: ${tokens.refresh_token.substring(0, 20)}...`);
      try {
        let envContent = fs.readFileSync(ENV_PATH, 'utf8');
        if (envContent.includes('GMAIL_REFRESH_TOKEN=')) {
          envContent = envContent.replace(/GMAIL_REFRESH_TOKEN=.*/, `GMAIL_REFRESH_TOKEN=${tokens.refresh_token}`);
        } else {
          envContent += `\nGMAIL_REFRESH_TOKEN=${tokens.refresh_token}\n`;
        }
        fs.writeFileSync(ENV_PATH, envContent);
        msg += '<p style="color:#4ade80">Token auto-saved to central .env!</p>';
        audit('steve-office', 'oauth-authorized', { scopes: FULL_WORKSPACE_SCOPES.length });
      } catch (e) {
        msg += `<p style="color:#f87171">Could not auto-save: ${e.message}</p>`;
      }
    } else {
      msg += '<p>Access token refreshed (existing refresh token reused).</p>';
    }
    msg += '<p style="color:#4ade80">Full Google Workspace is now ready for steve@.</p>';
    msg += '<p><a href="/" style="color:#60a5fa">Go to Dashboard</a></p></body></html>';
    res.send(msg);
  } catch (e) {
    console.error(`[${AGENT_NAME}] OAuth callback error:`, e.message);
    res.status(500).send(`<html><body style="font-family:sans-serif;background:#0f1117;color:#f87171;padding:40px"><h2>Authorization Failed</h2><p>${e.message}</p><p><a href="/auth" style="color:#60a5fa">Try Again</a></p></body></html>`);
  }
});

// ─── Info@ OAuth Authorization Flow ───
app.get('/auth/info', (req, res) => {
  if (!infoOauth2Client) return res.status(500).send('OAuth client not configured');
  const url = infoOauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: INFO_SCOPES,
    prompt: 'consent',
    redirect_uri: INFO_REDIRECT_URI,
    login_hint: 'info@designerwallcoverings.com',
    state: 'info',
  });
  res.redirect(url);
});

app.get('/api/auth-url/info', (req, res) => {
  if (!infoOauth2Client) return res.status(500).json({ error: 'OAuth client not configured' });
  const url = infoOauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: INFO_SCOPES,
    prompt: 'consent',
    redirect_uri: INFO_REDIRECT_URI,
    login_hint: 'info@designerwallcoverings.com',
    state: 'info',
  });
  res.json({ authUrl: url, account: 'info@designerwallcoverings.com', scopes: INFO_SCOPES, instructions: 'Visit this URL in a browser where you are logged into info@designerwallcoverings.com. Approve access, then copy the code from the redirect URL and pass to /api/exchange-code/info?code=YOUR_CODE' });
});

// oauth2callback-info is handled by the unified /oauth2callback via state=info

// ─── Agent Abrams OAuth Authorization Flow ───
app.get('/auth/agentabrams', (req, res) => {
  if (!agentabramsOauth2Client) return res.status(500).send('Agent Abrams OAuth client not configured');
  const url = agentabramsOauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: FULL_WORKSPACE_SCOPES,
    prompt: 'consent',
    redirect_uri: AGENTABRAMS_REDIRECT_URI,
    login_hint: AGENTABRAMS_LOGIN_HINT,
    state: 'agentabrams',
  });
  res.redirect(url);
});

app.get('/api/auth-url/agentabrams', (req, res) => {
  if (!agentabramsOauth2Client) return res.status(500).json({ error: 'Agent Abrams OAuth client not configured' });
  const url = agentabramsOauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: FULL_WORKSPACE_SCOPES,
    prompt: 'consent',
    redirect_uri: AGENTABRAMS_REDIRECT_URI,
    login_hint: AGENTABRAMS_LOGIN_HINT,
    state: 'agentabrams',
  });
  res.json({ authUrl: url, account: AGENTABRAMS_LOGIN_HINT, scopes: FULL_WORKSPACE_SCOPES });
});

app.get('/api/exchange-code/agentabrams', async (req, res) => {
  try {
    const { code } = req.query;
    if (!code) return res.status(400).json({ error: 'code parameter required' });
    const { tokens } = await agentabramsOauth2Client.getToken({ code, redirect_uri: AGENTABRAMS_REDIRECT_URI });
    agentabramsOauth2Client.setCredentials(tokens);
    agentabramsGmail    = google.gmail   ({ version: 'v1', auth: agentabramsOauth2Client });
    agentabramsTasksApi = google.tasks   ({ version: 'v1', auth: agentabramsOauth2Client });
    agentabramsDrive    = google.drive   ({ version: 'v3', auth: agentabramsOauth2Client });
    agentabramsDocs     = google.docs    ({ version: 'v1', auth: agentabramsOauth2Client });
    agentabramsSheets   = google.sheets  ({ version: 'v4', auth: agentabramsOauth2Client });
    agentabramsSlides   = google.slides  ({ version: 'v1', auth: agentabramsOauth2Client });
    agentabramsCalendar = google.calendar({ version: 'v3', auth: agentabramsOauth2Client });
    agentabramsForms    = google.forms   ({ version: 'v1', auth: agentabramsOauth2Client });

    const result = { success: true, account: 'agentabrams', hasRefreshToken: !!tokens.refresh_token };
    if (tokens.refresh_token) {
      result.refreshToken = tokens.refresh_token;
      try {
        let envContent = fs.readFileSync(ENV_PATH, 'utf8');
        if (envContent.includes('AGENTABRAMS_REFRESH_TOKEN=')) {
          envContent = envContent.replace(/AGENTABRAMS_REFRESH_TOKEN=.*/, `AGENTABRAMS_REFRESH_TOKEN=${tokens.refresh_token}`);
        } else {
          envContent += `\n# Agent Abrams (theagentabrams@gmail.com) OAuth Token\nAGENTABRAMS_REFRESH_TOKEN=${tokens.refresh_token}\n`;
        }
        fs.writeFileSync(ENV_PATH, envContent);
        result.savedToEnv = true;
        audit('agentabrams', 'oauth-authorized', { scopes: FULL_WORKSPACE_SCOPES.length });
      } catch (e) {
        result.savedToEnv = false;
        result.envError = e.message;
      }
    }
    try {
      const profile = await agentabramsGmail.users.getProfile({ userId: 'me' });
      result.email = profile.data.emailAddress;
      result.ready = true;
    } catch (e) {
      result.ready = false;
      result.testError = e.message;
    }
    res.json(result);
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Steve Personal OAuth Authorization Flow ───
app.get('/auth/steve-personal', (req, res) => {
  if (!personalOauth2Client) return res.status(500).send('Personal OAuth client not configured');
  const url = personalOauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: PERSONAL_SCOPES,
    prompt: 'consent',
    redirect_uri: PERSONAL_REDIRECT_URI,
    login_hint: 'steveabramsdesigns@gmail.com',
    state: 'personal',
  });
  res.redirect(url);
});

app.get('/api/auth-url/steve-personal', (req, res) => {
  if (!personalOauth2Client) return res.status(500).json({ error: 'Personal OAuth client not configured' });
  const url = personalOauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: PERSONAL_SCOPES,
    prompt: 'consent',
    redirect_uri: PERSONAL_REDIRECT_URI,
    login_hint: 'steveabramsdesigns@gmail.com',
    state: 'personal',
  });
  res.json({ authUrl: url, account: 'steveabramsdesigns@gmail.com', scopes: PERSONAL_SCOPES });
});

app.get('/api/exchange-code/steve-personal', async (req, res) => {
  try {
    const { code } = req.query;
    if (!code) return res.status(400).json({ error: 'code parameter required' });
    const { tokens } = await personalOauth2Client.getToken({ code, redirect_uri: PERSONAL_REDIRECT_URI });
    personalOauth2Client.setCredentials(tokens);
    personalGmail = google.gmail({ version: 'v1', auth: personalOauth2Client });
    personalTasksApi = google.tasks({ version: 'v1', auth: personalOauth2Client });
    personalDrive = google.drive({ version: 'v3', auth: personalOauth2Client });
    personalDocs = google.docs({ version: 'v1', auth: personalOauth2Client });
    personalSheets = google.sheets({ version: 'v4', auth: personalOauth2Client });
    personalSlides = google.slides({ version: 'v1', auth: personalOauth2Client });
    personalCalendar = google.calendar({ version: 'v3', auth: personalOauth2Client });
    personalForms = google.forms({ version: 'v1', auth: personalOauth2Client });

    const result = { success: true, account: 'steve-personal', hasRefreshToken: !!tokens.refresh_token };
    if (tokens.refresh_token) {
      result.refreshToken = tokens.refresh_token;
      try {
        let envContent = fs.readFileSync(ENV_PATH, 'utf8');
        if (envContent.includes('PERSONAL_REFRESH_TOKEN=')) {
          envContent = envContent.replace(/PERSONAL_REFRESH_TOKEN=.*/, `PERSONAL_REFRESH_TOKEN=${tokens.refresh_token}`);
        } else {
          envContent += `\n# Steve Personal (steveabramsdesigns@gmail.com) OAuth Token\nPERSONAL_REFRESH_TOKEN=${tokens.refresh_token}\n`;
        }
        fs.writeFileSync(ENV_PATH, envContent);
        result.savedToEnv = true;
        audit('steve-personal', 'oauth-authorized', { scopes: PERSONAL_SCOPES.length });
      } catch (e) {
        result.savedToEnv = false;
        result.envError = e.message;
      }
    }
    try {
      const profile = await personalGmail.users.getProfile({ userId: 'me' });
      result.email = profile.data.emailAddress;
      result.ready = true;
    } catch (e) {
      result.ready = false;
      result.testError = e.message;
    }
    res.json(result);
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/exchange-code/info', async (req, res) => {
  try {
    const { code } = req.query;
    if (!code) return res.status(400).json({ error: 'code parameter required' });
    const { tokens } = await infoOauth2Client.getToken({ code, redirect_uri: INFO_REDIRECT_URI });
    infoOauth2Client.setCredentials(tokens);
    infoGmail = google.gmail({ version: 'v1', auth: infoOauth2Client });
    infoTasksApi = google.tasks({ version: 'v1', auth: infoOauth2Client });
    infoDrive = google.drive({ version: 'v3', auth: infoOauth2Client });

    const result = { success: true, account: 'info@', hasRefreshToken: !!tokens.refresh_token };
    if (tokens.refresh_token) {
      result.refreshToken = tokens.refresh_token;
      try {
        let envContent = fs.readFileSync(ENV_PATH, 'utf8');
        if (envContent.includes('INFO_REFRESH_TOKEN=')) {
          envContent = envContent.replace(/INFO_REFRESH_TOKEN=.*/, `INFO_REFRESH_TOKEN=${tokens.refresh_token}`);
        } else {
          envContent += `\n# Info@ Account OAuth Token\nINFO_REFRESH_TOKEN=${tokens.refresh_token}\n`;
        }
        fs.writeFileSync(ENV_PATH, envContent);
        result.savedToEnv = true;
      } catch (e) {
        result.savedToEnv = false;
        result.envError = e.message;
      }
    }
    try {
      const profile = await infoGmail.users.getProfile({ userId: 'me' });
      result.email = profile.data.emailAddress;
      result.ready = true;
    } catch (e) {
      result.ready = false;
      result.testError = e.message;
    }
    res.json(result);
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Manual Code Exchange (for localhost redirect flow) ───
app.get('/api/exchange-code', async (req, res) => {
  try {
    const { code } = req.query;
    if (!code) return res.status(400).json({ error: 'code parameter required' });
    const { tokens } = await oauth2Client.getToken({ code, redirect_uri: OAUTH_REDIRECT });
    oauth2Client.setCredentials(tokens);
    gmail = google.gmail({ version: 'v1', auth: oauth2Client });
    tasksApi = google.tasks({ version: 'v1', auth: oauth2Client });

    const result = { success: true, hasRefreshToken: !!tokens.refresh_token };
    if (tokens.refresh_token) {
      result.refreshToken = tokens.refresh_token;
      // Auto-save refresh token to .env
      try {
        let envContent = fs.readFileSync(ENV_PATH, 'utf8');
        if (envContent.includes('GMAIL_REFRESH_TOKEN=')) {
          envContent = envContent.replace(/GMAIL_REFRESH_TOKEN=.*/, `GMAIL_REFRESH_TOKEN=${tokens.refresh_token}`);
        } else {
          envContent += `\nGMAIL_REFRESH_TOKEN=${tokens.refresh_token}\n`;
        }
        fs.writeFileSync(ENV_PATH, envContent);
        result.savedToEnv = true;
        console.log(`[${AGENT_NAME}] Refresh token saved to ${ENV_PATH}`);
      } catch (e) {
        result.savedToEnv = false;
        result.envError = e.message;
      }
    }
    // Quick test
    try {
      const profile = await gmail.users.getProfile({ userId: 'me' });
      result.email = profile.data.emailAddress;
      result.gmailReady = true;
    } catch (e) {
      result.gmailReady = false;
      result.testError = e.message;
    }
    res.json(result);
  } catch (e) {
    console.error(`[${AGENT_NAME}] Code exchange error:`, e.message);
    res.status(500).json({ error: e.message });
  }
});

// ─── Auth URL Generator (returns URL instead of redirecting) ───
app.get('/api/auth-url', (req, res) => {
  if (!oauth2Client) return res.status(500).json({ error: 'OAuth client not configured' });
  const url = oauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: SCOPES,
    prompt: 'consent',
    redirect_uri: OAUTH_REDIRECT,
  });
  res.json({ authUrl: url, instructions: 'Visit this URL, approve access, then copy the full redirect URL from your browser address bar and pass the code parameter to /api/exchange-code?code=YOUR_CODE' });
});

// ─── Basic Auth (exempt health + OAuth routes) ───
app.use((req, res, next) => {
  const publicApiPaths = [
    '/health', '/api/health', '/auth', '/auth/info', '/auth/steve-office', '/auth/steve-personal', '/auth/agentabrams',
    '/oauth2callback', '/oauth2callback-info',
    '/api/exchange-code', '/api/exchange-code/info', '/api/exchange-code/steve-personal',
    '/api/auth-url', '/api/auth-url/info', '/api/auth-url/steve-personal', '/api/auth-url/agentabrams'
  ];
  if (publicApiPaths.includes(req.path)) return next();
  const auth = req.headers.authorization;
  if (!auth || !auth.startsWith('Basic ')) return res.status(401).json({ error: 'Auth required' });
  const [user, pass] = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':');
  // Single source of truth: same resolved credential as the primary middleware
  // (the old separate GEORGE_BASIC_AUTH_PASS check caused the 2026-07 split-brain
  // where no credential could pass both stacked middlewares once the vars diverged).
  if (!_ctEq(user, _GEORGE_USER) || !_ctEq(pass, _GEORGE_PASS)) return res.status(401).json({ error: 'Invalid credentials' });
  next();
});

// ─── Helper: decode email parts ───
function decodeBody(parts) {
  if (!parts) return '';
  for (const part of parts) {
    if (part.mimeType === 'text/plain' && part.body?.data) {
      return Buffer.from(part.body.data, 'base64url').toString('utf8');
    }
    if (part.parts) {
      const nested = decodeBody(part.parts);
      if (nested) return nested;
    }
  }
  return '';
}

function getHeader(headers, name) {
  const h = (headers || []).find(h => h.name.toLowerCase() === name.toLowerCase());
  return h ? h.value : '';
}

// ─── API: Health Check ───
app.get('/api/health', (req, res) => {
  res.json({
    agent: AGENT_NAME,
    codename: AGENT_CODENAME,
    status: gmail ? 'ready' : 'no-gmail-client',
    port: PORT,
    hasCredentials: !!(GMAIL_CLIENT_ID && GMAIL_REFRESH_TOKEN),
    uptime: process.uptime()
  });
});

// ─── API: Status + Profile ───
app.get('/api/status', (req, res) => {
  res.json({
    agent: AGENT_NAME,
    codename: AGENT_CODENAME,
    color: AGENT_COLOR,
    port: PORT,
    status: gmail ? 'online' : 'error',
    hasCredentials: !!(GMAIL_CLIENT_ID && GMAIL_REFRESH_TOKEN),
    capabilities: ['list', 'read', 'search', 'send', 'labels', 'attachments'],
    envSource: ENV_PATH
  });
});

// ─── API: Get Profile (email address, history) ───
app.get('/api/profile', async (req, res) => {
  try {
    const { gmail: gm, key: account } = resolveAccount(req);
    if (!gm) return res.status(400).json({ error: `Account "${account}" not configured` });
    const profile = await gm.users.getProfile({ userId: 'me' });
    res.json(profile.data);
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Raw headers for a message (debug auth/SPF/DKIM/DMARC) ───
app.get('/api/messages/:id/headers', async (req, res) => {
  try {
    const { gmail: g, key: account } = resolveAccount(req);
    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
    const r = await g.users.messages.get({ userId: 'me', id: req.params.id, format: 'metadata' });
    const headers = (r.data.payload?.headers || []).reduce((acc, h) => {
      const k = h.name;
      acc[k] = acc[k] ? `${acc[k]}\n${h.value}` : h.value;
      return acc;
    }, {});
    res.json({ id: r.data.id, labelIds: r.data.labelIds, headers });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Trash a message ───
app.post('/api/messages/:id/trash', async (req, res) => {
  try {
    const { gmail: g, key: account } = resolveAccount(req);
    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
    const result = await g.users.messages.trash({ userId: 'me', id: req.params.id });
    audit(account, 'trash', { messageId: req.params.id });
    res.json({ success: true, messageId: result.data.id, account });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Batch trash by query ───
app.post('/api/messages/trash-by-query', async (req, res) => {
  try {
    const { q } = req.body;
    if (!q) return res.status(400).json({ error: 'q (search query) required' });
    const { gmail: g, key: account } = resolveAccount(req);
    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
    const list = await g.users.messages.list({ userId: 'me', q, maxResults: 500 });
    const ids = (list.data.messages || []).map(m => m.id);
    if (ids.length === 0) return res.json({ success: true, trashed: 0, ids: [] });
    await g.users.messages.batchModify({ userId: 'me', requestBody: { ids, addLabelIds: ['TRASH'], removeLabelIds: ['INBOX'] } });
    audit(account, 'batch-trash', { q, count: ids.length });
    res.json({ success: true, trashed: ids.length, ids, account });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: List Labels ───
app.get('/api/labels', async (req, res) => {
  try {
    const result = await gmail.users.labels.list({ userId: 'me' });
    res.json(result.data.labels || []);
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: List Messages ───
app.get('/api/messages', async (req, res) => {
  try {
    const { q, maxResults, labelIds, pageToken } = req.query;
    const { gmail: gm, key: account } = resolveAccount(req);
    if (!gm) return res.status(400).json({ error: `unknown account: ${account}` });
    const params = { userId: 'me', maxResults: parseInt(maxResults) || 20 };
    if (q) params.q = q;
    if (labelIds) params.labelIds = labelIds.split(',');
    if (pageToken) params.pageToken = pageToken;

    const list = await gm.users.messages.list(params);
    const messages = list.data.messages || [];

    // Fetch headers for each message (batch)
    const detailed = await Promise.all(
      messages.slice(0, parseInt(maxResults) || 20).map(async m => {
        try {
          const full = await gm.users.messages.get({ userId: 'me', id: m.id, format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date', 'To'] });
          return {
            id: full.data.id,
            threadId: full.data.threadId,
            snippet: full.data.snippet,
            labelIds: full.data.labelIds,
            subject: getHeader(full.data.payload?.headers, 'Subject'),
            from: getHeader(full.data.payload?.headers, 'From'),
            to: getHeader(full.data.payload?.headers, 'To'),
            date: getHeader(full.data.payload?.headers, 'Date'),
            internalDate: full.data.internalDate
          };
        } catch { return { id: m.id, error: 'fetch-failed' }; }
      })
    );

    res.json({
      messages: detailed,
      nextPageToken: list.data.nextPageToken,
      resultSizeEstimate: list.data.resultSizeEstimate
    });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Read Single Message ───
app.get('/api/messages/:id', async (req, res) => {
  try {
    const { gmail: gm, key: account } = resolveAccount(req);
    if (!gm) return res.status(400).json({ error: `unknown account: ${account}` });
    const msg = await gm.users.messages.get({ userId: 'me', id: req.params.id, format: 'full' });
    const headers = msg.data.payload?.headers || [];
    const body = msg.data.payload?.body?.data
      ? Buffer.from(msg.data.payload.body.data, 'base64url').toString('utf8')
      : decodeBody(msg.data.payload?.parts);

    const attachments = [];
    function findAttachments(parts) {
      if (!parts) return;
      for (const p of parts) {
        if (p.filename && p.body?.attachmentId) {
          attachments.push({ filename: p.filename, mimeType: p.mimeType, size: p.body.size, attachmentId: p.body.attachmentId });
        }
        if (p.parts) findAttachments(p.parts);
      }
    }
    findAttachments(msg.data.payload?.parts);

    res.json({
      id: msg.data.id,
      threadId: msg.data.threadId,
      labelIds: msg.data.labelIds,
      snippet: msg.data.snippet,
      subject: getHeader(headers, 'Subject'),
      from: getHeader(headers, 'From'),
      to: getHeader(headers, 'To'),
      cc: getHeader(headers, 'Cc'),
      date: getHeader(headers, 'Date'),
      body,
      attachments,
      internalDate: msg.data.internalDate
    });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Fetch attachment → save to /tmp (normal George auth) ───
// Added 2026-07-07 (Steve-authorized): pull a Gmail attachment by filename
// substring and write it under /tmp for local processing. Goes through the
// standard Basic-Auth gate like every other /api route; only writes to /tmp/.
app.get('/api/attachment-local', async (req, res) => {
  try {
    const { messageId, filename, save } = req.query;
    if (!messageId || !filename || !save) return res.status(400).json({ error: 'messageId, filename, save required' });
    const savePath = require('path').resolve(String(save));
    if (!savePath.startsWith('/tmp/')) return res.status(400).json({ error: 'save path must be under /tmp/' });

    const { gmail: gm, key: account } = resolveAccount(req);
    if (!gm) return res.status(400).json({ error: `unknown account: ${account}` });

    const msg = await gm.users.messages.get({ userId: 'me', id: String(messageId), format: 'full' });
    let match = null;
    (function walk(parts) {
      if (!parts || match) return;
      for (const p of parts) {
        if (p.filename && p.body?.attachmentId && p.filename.toLowerCase().includes(String(filename).toLowerCase())) { match = p; return; }
        if (p.parts) walk(p.parts);
      }
    })(msg.data.payload?.parts);
    if (!match) return res.status(404).json({ error: `no attachment matching "${filename}"` });

    const att = await gm.users.messages.attachments.get({ userId: 'me', messageId: String(messageId), id: match.body.attachmentId });
    const buf = Buffer.from(att.data.data, 'base64url');
    require('fs').writeFileSync(savePath, buf);
    audit(account, 'attachment-local', { messageId, filename: match.filename, bytes: buf.length, save: savePath });
    res.json({ saved: savePath, filename: match.filename, mimeType: match.mimeType, bytes: buf.length });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Search Messages ───
app.get('/api/search', async (req, res) => {
  try {
    const { q, maxResults } = req.query;
    if (!q) return res.status(400).json({ error: 'Query (q) required' });
    const { gmail: gm, key: account } = resolveAccount(req);
    if (!gm) return res.status(400).json({ error: `unknown account: ${account}` });
    const list = await gm.users.messages.list({ userId: 'me', q, maxResults: parseInt(maxResults) || 10 });
    const messages = list.data.messages || [];

    const detailed = await Promise.all(
      messages.map(async m => {
        try {
          const full = await gm.users.messages.get({ userId: 'me', id: m.id, format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date'] });
          return {
            id: full.data.id,
            snippet: full.data.snippet,
            subject: getHeader(full.data.payload?.headers, 'Subject'),
            from: getHeader(full.data.payload?.headers, 'From'),
            date: getHeader(full.data.payload?.headers, 'Date')
          };
        } catch { return { id: m.id, error: 'fetch-failed' }; }
      })
    );

    res.json({ query: q, messages: detailed, total: list.data.resultSizeEstimate });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Send Email ───
// RFC 2047 encoded-word for any header containing non-ASCII (em-dash, curly quotes, accented chars, etc.)
function encodeMimeHeader(s) {
  if (!s) return s;
  // eslint-disable-next-line no-control-regex
  return /[^\x00-\x7F]/.test(s)
    ? `=?UTF-8?B?${Buffer.from(s, 'utf8').toString('base64')}?=`
    : s;
}

// Tag every outbound body with a "where-it's-from" header — so Steve can spot
// at a glance which job/script/agent triggered the email. Source comes from
// req.body.source (preferred), req.body.job, req.headers['x-george-source'],
// or falls back to the user-agent string.
function inferSource(body, req) {
  if (body && (body.no_source_tag || body.noSourceTag)) return '';   // opt out of the From-job banner
  if (req?.headers?.['x-george-no-tag']) return '';
  const raw = body.source || body.job || body.from_job || body.origin
           || req?.headers?.['x-george-source']
           || req?.headers?.['user-agent']
           || 'unspecified';
  return String(raw).slice(0, 120);
}
function withSourceFooter(htmlBody, source) {
  if (!source) return htmlBody || '';   // empty source -> no banner (customer-facing send)
  const ts = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
  const tag = `<div style="font-family:ui-monospace,Menlo,monospace;font-size:10px;letter-spacing:.10em;text-transform:uppercase;color:#7a766f;padding:8px 12px;background:rgba(212,160,74,0.08);border-left:3px solid #d4a04a;margin-bottom:18px;border-radius:2px"><strong style="color:#d4a04a">From job:</strong> ${escapeHtml(source)} <span style="color:#a8a39c">·</span> ${ts}</div>`;
  return tag + (htmlBody || '');
}
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }

// Build a UTF-8-clean RFC 2822 message: encoded subject, MIME-Version, base64 body (76-char lines per RFC 2045).
// inReplyTo / references are RFC Message-Id values (Gmail returns these from messages.get headers).
function buildRawMessage({ from, to, cc, bcc, subject, body, inReplyTo, references }) {
  const bodyB64 = Buffer.from(body, 'utf8').toString('base64').replace(/(.{76})/g, '$1\r\n');
  const refsHeader = references && references.length ? references.join(' ') : (inReplyTo || null);
  const headerLines = [
    from ? `From: ${from}` : null,
    to ? `To: ${to}` : null,
    cc ? `Cc: ${cc}` : null,
    bcc ? `Bcc: ${bcc}` : null,
    `Subject: ${encodeMimeHeader(subject)}`,
    inReplyTo ? `In-Reply-To: ${inReplyTo}` : null,
    refsHeader ? `References: ${refsHeader}` : null,
    'MIME-Version: 1.0',
    'Content-Type: text/html; charset=UTF-8',
    'Content-Transfer-Encoding: base64',
  ].filter(Boolean);
  const message = headerLines.join('\r\n') + '\r\n\r\n' + bodyB64;
  return Buffer.from(message, 'utf8').toString('base64url');
}

// Look up RFC Message-Id + thread for a previously-sent message id, so callers
// can reply in-thread by passing only the gmail message id (not the raw header).
async function resolveReplyContext(gmail, replyToMessageId) {
  const m = await gmail.users.messages.get({
    userId: 'me', id: replyToMessageId, format: 'metadata',
    metadataHeaders: ['Message-Id', 'References', 'Subject'],
  });
  const headers = m.data.payload?.headers || [];
  const find = (n) => headers.find(h => h.name.toLowerCase() === n.toLowerCase())?.value || null;
  const messageIdHdr = find('Message-Id');
  const refs = find('References');
  const subject = find('Subject') || '';
  return {
    threadId: m.data.threadId,
    messageIdHeader: messageIdHdr,
    referencesHeader: [refs, messageIdHdr].filter(Boolean).join(' ').trim() || null,
    subject,
  };
}

// ── External-send guard (2026-06-17) ─────────────────────────────────────────
// Incident: an autonomous worker drained gated vendor-email memos and auto-sent
// them via George. Fix at the chokepoint — sends to Steve-owned addresses are
// always allowed; any send to an EXTERNAL recipient requires a human-approval
// token (header X-Send-Approval, or body.send_approval_token). Fail-closed.
const EXT_SEND_TOKEN = (() => {
  if (process.env.GEORGE_EXTERNAL_SEND_TOKEN) return process.env.GEORGE_EXTERNAL_SEND_TOKEN;
  try { const m = require('fs').readFileSync(require('path').join(__dirname, '.env'), 'utf8').match(/^GEORGE_EXTERNAL_SEND_TOKEN=(.+)$/m); return m ? m[1].trim() : ''; } catch (e) { return ''; }
})();
// read a key from .env directly (launchd doesn't load .env into process.env;
// same fallback the EXT_SEND_TOKEN above uses) so the allowlist is configurable
function _envFile(key) { try { const m = require('fs').readFileSync(require('path').join(__dirname, '.env'), 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')); return m ? m[1].trim() : ''; } catch (e) { return ''; } }
const INTERNAL_DOMAINS = (process.env.GEORGE_INTERNAL_DOMAINS || _envFile('GEORGE_INTERNAL_DOMAINS') || 'designerwallcoverings.com').toLowerCase().split(',').map(s => s.trim()).filter(Boolean);
const INTERNAL_ADDRS = (process.env.GEORGE_INTERNAL_ADDRS || _envFile('GEORGE_INTERNAL_ADDRS') || 'steveabramsdesigns@gmail.com,theagentabrams@gmail.com,wallsandfabrics@gmail.com').toLowerCase().split(',').map(s => s.trim()).filter(Boolean);
function _recips(...vals) { const out = []; for (const v of vals) { if (!v) continue; const arr = Array.isArray(v) ? v : String(v).split(','); for (const a of arr) { const m = String(a).match(/[\w.+-]+@[\w.-]+\.\w+/); if (m) out.push(m[0].toLowerCase()); } } return out; }
function _isInternal(addr) { const dom = (addr.split('@')[1] || ''); return INTERNAL_ADDRS.includes(addr) || INTERNAL_DOMAINS.includes(dom); }
function externalSendGuard(req) {
  const b = req.body || {};
  const external = _recips(b.to, b.cc, b.bcc).filter(a => !_isInternal(a));
  if (!external.length) return { ok: true };
  const got = (req.get && req.get('x-send-approval')) || b.send_approval_token || '';
  if (EXT_SEND_TOKEN && got && got === EXT_SEND_TOKEN) return { ok: true, external };
  return { ok: false, external, reason: (EXT_SEND_TOKEN
    ? 'external send blocked — missing/invalid human-approval token (pass X-Send-Approval). '
    : 'external send blocked — GEORGE_EXTERNAL_SEND_TOKEN not configured (fail-closed). ')
    + 'External recipients: ' + external.join(', ') };
}

app.post('/api/send', async (req, res) => {
  try {
    const { from, to, subject, body, cc, bcc, threadId: bodyThreadId, inReplyTo: bodyInReplyTo, replyToMessageId, references } = req.body;
    if (!to || !body) return res.status(400).json({ error: 'to and body required' });
    { const _g = externalSendGuard(req); if (!_g.ok) { try { audit(req.body.account || '?', 'send-BLOCKED', { to, external: _g.external, reason: _g.reason }); } catch (e) {} return res.status(403).json({ error: _g.reason, blocked: true, external: _g.external }); } }

    const { gmail: g, key: account } = resolveAccount(req);
    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });

    let threadId = bodyThreadId || null;
    let inReplyTo = bodyInReplyTo || null;
    let refsArr = Array.isArray(references) ? references.slice() : null;
    let finalSubject = subject;

    // Convenience: caller supplied just the gmail message id of what to reply to → we resolve everything.
    if (replyToMessageId) {
      const ctx = await resolveReplyContext(g, replyToMessageId);
      threadId = threadId || ctx.threadId;
      inReplyTo = inReplyTo || ctx.messageIdHeader;
      if (!refsArr && ctx.referencesHeader) refsArr = ctx.referencesHeader.split(/\s+/);
      if (!finalSubject) finalSubject = ctx.subject?.startsWith('Re:') ? ctx.subject : `Re: ${ctx.subject || ''}`.trim();
    }
    if (!finalSubject) return res.status(400).json({ error: 'subject required (or pass replyToMessageId)' });

    const source = inferSource(req.body, req);
    const taggedBody = withSourceFooter(body, source);
    const encoded = buildRawMessage({ from, to, cc, bcc, subject: finalSubject, body: taggedBody, inReplyTo, references: refsArr });
    const requestBody = { raw: encoded };
    if (threadId) requestBody.threadId = threadId;
    const result = await g.users.messages.send({ userId: 'me', requestBody });
    audit(account, 'send', { from, to, subject: finalSubject, source, messageId: result.data.id, threadId: result.data.threadId, inReplyTo: inReplyTo || null });
    res.json({ success: true, messageId: result.data.id, threadId: result.data.threadId, account, source, inThread: Boolean(threadId || inReplyTo) });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: List Send-As aliases ───
app.get('/api/aliases', async (req, res) => {
  try {
    const { gmail: g, key: account } = resolveAccount(req);
    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
    const result = await g.users.settings.sendAs.list({ userId: 'me' });
    res.json({ account, aliases: result.data.sendAs || [] });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Create Send-As alias (triggers verification email) ───
// Body: { sendAsEmail, displayName?, treatAsAlias?, smtp?: {host,port,username,password,securityMode} }
// Without smtp, Gmail relays through its own servers (sender shown as "via gmail.com" in some clients).
// With smtp, relays through that SMTP server (cleaner DKIM-from-source-domain look).
app.post('/api/aliases/create', async (req, res) => {
  try {
    const { sendAsEmail, displayName, treatAsAlias, smtp } = req.body || {};
    if (!sendAsEmail) return res.status(400).json({ error: 'sendAsEmail required' });
    const { gmail: g, key: account } = resolveAccount(req);
    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
    const requestBody = { sendAsEmail, treatAsAlias: treatAsAlias !== false };
    if (displayName) requestBody.displayName = displayName;
    if (smtp && smtp.host) {
      requestBody.smtpMsa = {
        host: smtp.host,
        port: smtp.port || 465,
        username: smtp.username,
        password: smtp.password,
        securityMode: smtp.securityMode || 'ssl',
      };
    }
    const result = await g.users.settings.sendAs.create({ userId: 'me', requestBody });
    audit(account, 'alias-create', { sendAsEmail, smtp: !!smtp });
    res.json({ success: true, account, alias: { sendAsEmail: result.data.sendAsEmail, verificationStatus: result.data.verificationStatus, isPrimary: result.data.isPrimary, treatAsAlias: result.data.treatAsAlias } });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Verify Send-As alias by extracting verify link from inbox + fetching it ───
// Body: { sendAsEmail, account: "main"|"info" } — searches for the most recent verification mail and clicks it
app.post('/api/aliases/verify-from-inbox', async (req, res) => {
  try {
    const { sendAsEmail } = req.body || {};
    if (!sendAsEmail) return res.status(400).json({ error: 'sendAsEmail required' });
    const { gmail: g, key: account } = resolveAccount(req);
    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
    // Search for the most recent verification email related to this address
    const q = `from:noreply-confirmation@google.com OR from:gmail-noreply@google.com to:${sendAsEmail} subject:Confirmation newer_than:1d`;
    const list = await g.users.messages.list({ userId: 'me', q, maxResults: 5 });
    const messages = list.data.messages || [];
    if (messages.length === 0) {
      // Fallback: any subaddress version of the from address
      const m = sendAsEmail.match(/^([^@]+)@(.+)$/);
      if (m) {
        const tagged = `steve+${m[2].replace(/\./g, '-')}@designerwallcoverings.com`;
        const q2 = `to:${tagged} subject:Confirmation newer_than:1d`;
        const r2 = await g.users.messages.list({ userId: 'me', q: q2, maxResults: 5 });
        if (r2.data.messages) messages.push(...r2.data.messages);
      }
    }
    if (messages.length === 0) return res.status(404).json({ error: 'no verification email found in last 24h', sendAsEmail });
    // Get the most recent message body
    const msg = await g.users.messages.get({ userId: 'me', id: messages[0].id, format: 'full' });
    function findUrl(payload) {
      if (!payload) return null;
      if (payload.body && payload.body.data) {
        const text = Buffer.from(payload.body.data, 'base64url').toString('utf8');
        const urls = text.match(/https?:\/\/[^\s)>"]*ConfirmAddress[^\s)>"]*/g) || text.match(/https?:\/\/mail-settings\.google\.com[^\s)>"]+/g);
        if (urls) return urls[0];
      }
      if (payload.parts) for (const p of payload.parts) { const u = findUrl(p); if (u) return u; }
      return null;
    }
    const url = findUrl(msg.data.payload);
    if (!url) return res.status(404).json({ error: 'no verify URL in message', messageId: messages[0].id });
    // Fetch URL = clicks verify
    const r = await fetch(url, { redirect: 'follow' }).catch(e => ({ ok: false, status: 0, error: e.message }));
    audit(account, 'alias-verify-clicked', { sendAsEmail, status: r.status });
    res.json({ success: true, account, sendAsEmail, verifyUrl: url.slice(0, 80) + '...', clickStatus: r.status });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Configure Send-As alias smtpMsa to use PurelyMail SMTP ───
// Body: { sendAsEmail: "info@<domain>" }
// Uses relay@hospitalitywallcoverings.com PM SMTP creds (password from /root/.purelymail-relay-pw)
app.post('/api/aliases/configure-smtp', async (req, res) => {
  try {
    const { sendAsEmail } = req.body;
    if (!sendAsEmail) return res.status(400).json({ error: 'sendAsEmail required' });
    const { gmail: g, key: account } = resolveAccount(req);
    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
    let pmPass;
    try { pmPass = fs.readFileSync('/root/.purelymail-relay-pw', 'utf8').trim(); }
    catch { return res.status(500).json({ error: 'PM relay password file missing on server' }); }
    const result = await g.users.settings.sendAs.update({
      userId: 'me',
      sendAsEmail,
      requestBody: {
        sendAsEmail,
        treatAsAlias: true,
        smtpMsa: {
          host: 'smtp.purelymail.com',
          port: 465,
          username: 'relay@hospitalitywallcoverings.com',
          password: pmPass,
          securityMode: 'ssl'
        }
      }
    });
    audit(account, 'configure-smtp', { sendAsEmail });
    // Don't return the password back — Gmail's response also has it as null
    res.json({ success: true, account, sendAsEmail, smtpMsa: result.data.smtpMsa, treatAsAlias: result.data.treatAsAlias });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Send with Attachments ───
// Body: { to, cc?, bcc?, subject, body (html), attachments: [{ filename, content_base64, mime_type? }], account?: "main"|"info" }
app.post('/api/send-with-attachment', async (req, res) => {
  try {
    const { to, cc, bcc, subject, body, attachments, account } = req.body;
    if (!to || !subject || !body) return res.status(400).json({ error: 'to, subject, and body required' });
    if (!attachments || !Array.isArray(attachments) || attachments.length === 0) {
      return res.status(400).json({ error: 'attachments array required' });
    }
    { const _g = externalSendGuard(req); if (!_g.ok) { try { audit(account || '?', 'send-BLOCKED', { to, external: _g.external, reason: _g.reason }); } catch (e) {} return res.status(403).json({ error: _g.reason, blocked: true, external: _g.external }); } }
    // Resolve any account (steve-office / info / steve-personal / agentabrams),
    // matching /api/send — not just office/info.
    const { gmail: gmailClient, oauth: authClient, key: acctKey } = resolveAccount(req);
    if (!authClient || !gmailClient) {
      return res.status(500).json({ error: `account not configured: ${acctKey}` });
    }

    // Build MIME with nodemailer (handles UTF-8 subjects, multipart, base64 chunking correctly)
    const profile = await gmailClient.users.getProfile({ userId: 'me' });
    const fromAddr = profile.data.emailAddress;
    const source = inferSource(req.body, req);
    const taggedBody = withSourceFooter(body, source);
    const mailOpts = {
      from: fromAddr,
      to,
      subject,
      html: taggedBody,
      attachments: attachments.map(a => ({
        filename: a.filename || 'attachment.bin',
        content: Buffer.from(a.content_base64, 'base64'),
        contentType: a.mime_type || 'application/octet-stream',
      })),
    };
    if (cc) mailOpts.cc = cc;
    if (bcc) mailOpts.bcc = bcc;

    const mime = await new Promise((resolve, reject) => {
      const mail = nodemailer.createTransport({ streamTransport: true, buffer: true }).sendMail(
        mailOpts,
        (err, info) => (err ? reject(err) : resolve(info.message))
      );
    });
    const raw = Buffer.from(mime).toString('base64url');
    const result = await gmailClient.users.messages.send({ userId: 'me', requestBody: { raw } });
    res.json({
      success: true,
      messageId: result.data.id,
      threadId: result.data.threadId,
      from: fromAddr,
      account: acctKey,
    });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Create Draft ───
app.post('/api/drafts', async (req, res) => {
  try {
    const { to, subject, body, cc, bcc } = req.body;
    if (!subject || !body) return res.status(400).json({ error: 'subject and body required' });

    const { gmail: g, key: account } = resolveAccount(req);
    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });

    const source = inferSource(req.body, req);
    const taggedBody = withSourceFooter(body, source);
    const encoded = buildRawMessage({ to, cc, bcc, subject, body: taggedBody });
    const result = await g.users.drafts.create({ userId: 'me', requestBody: { message: { raw: encoded } } });
    audit(account, 'draft', { to, subject, source, draftId: result.data.id });
    res.json({ success: true, draftId: result.data.id, messageId: result.data.message?.id, account, source });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: List Drafts ───
app.get('/api/drafts', async (req, res) => {
  try {
    const { gmail: g, key: account } = resolveAccount(req);
    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
    const result = await g.users.drafts.list({ userId: 'me', maxResults: parseInt(req.query.maxResults) || 10 });
    res.json(result.data.drafts || []);
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Delete a Draft ───
// DELETE /api/drafts/:id?account=info  — permanently removes the draft (not trash).
app.delete('/api/drafts/:id', async (req, res) => {
  try {
    const { gmail: g, key: account } = resolveAccount(req);
    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
    await g.users.drafts.delete({ userId: 'me', id: req.params.id });
    audit(account, 'draft-delete', { draftId: req.params.id });
    res.json({ success: true, draftId: req.params.id, account });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── API: Get Attachment (account-aware, raw binary) ───
// Fixed 2026-07-10: was hardcoded to the steve-office `gmail` client AND
// returned base64 JSON. Cross-account requests (?account=info) failed with a
// 500 "Permission denied" because steve-office can't read an info@ message id,
// and the MCP bridge (georgeRaw → arrayBuffer) expects raw bytes, not JSON.
// Now resolves the correct per-account client and streams raw binary like the
// /api/info/... route does, so the MCP writes correct file bytes for any account.
app.get('/api/messages/:messageId/attachments/:attachmentId', async (req, res) => {
  try {
    const { key, gmail: gm } = resolveAccount(req);
    if (!gm) return res.status(503).json({ error: `Gmail not initialized for account '${key}' — authorize via /auth/${key}` });
    const att = await gm.users.messages.attachments.get({
      userId: 'me', messageId: req.params.messageId, id: req.params.attachmentId
    });
    const buf = Buffer.from(att.data.data, 'base64url');
    res.set('Content-Length', buf.length);
    res.type('application/octet-stream');
    res.send(buf);
  } catch (e) {
    console.error('[attachment] error:', e?.code, e?.message, JSON.stringify(e?.errors || e?.response?.data || {}));
    res.status(500).json({ error: e.message, code: e?.code, detail: e?.errors || e?.response?.data?.error || null });
  }
});

// ─── Slack Webhook for notifications ───
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL || '';
function slackNotify(text) {
  try {
    const https = require('https');
    const url = new URL(SLACK_WEBHOOK);
    const payload = JSON.stringify({ text });
    const req = https.request({ hostname: url.hostname, path: url.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } });
    req.write(payload);
    req.end();
  } catch {}
}

// ─── Google Tasks API ───
app.get('/api/tasks/lists', async (req, res) => {
  try {
    if (!tasksApi) return res.status(503).json({ error: 'Tasks API not initialized — re-authorize at /auth' });
    const result = await tasksApi.tasklists.list({ maxResults: 100 });
    res.json({ lists: result.data.items || [] });
  } catch (e) {
    if (e.message.includes('insufficient')) {
      return res.status(403).json({ error: 'Tasks scope not authorized. Visit /auth to grant permission.', authUrl: '/auth' });
    }
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/tasks/:listId', async (req, res) => {
  try {
    if (!tasksApi) return res.status(503).json({ error: 'Tasks API not initialized — re-authorize at /auth' });
    const showCompleted = req.query.showCompleted !== 'false';
    const showHidden = req.query.showHidden === 'true';
    const result = await tasksApi.tasks.list({
      tasklist: req.params.listId,
      maxResults: parseInt(req.query.maxResults) || 100,
      showCompleted,
      showHidden,
    });
    const tasks = (result.data.items || []).map(t => ({
      id: t.id,
      title: t.title,
      notes: t.notes || null,
      status: t.status,
      due: t.due || null,
      completed: t.completed || null,
      updated: t.updated,
      parent: t.parent || null,
      position: t.position,
      links: t.links || [],
    }));
    res.json({ listId: req.params.listId, count: tasks.length, tasks });
  } catch (e) {
    if (e.message.includes('insufficient')) {
      return res.status(403).json({ error: 'Tasks scope not authorized. Visit /auth to grant permission.', authUrl: '/auth' });
    }
    res.status(500).json({ error: e.message });
  }
});

// Convenience: get all tasks across all lists
app.get('/api/tasks', async (req, res) => {
  try {
    if (!tasksApi) return res.status(503).json({ error: 'Tasks API not initialized — re-authorize at /auth' });
    const listsResult = await tasksApi.tasklists.list({ maxResults: 100 });
    const lists = listsResult.data.items || [];
    const allTasks = [];
    for (const list of lists) {
      const tasksResult = await tasksApi.tasks.list({
        tasklist: list.id,
        maxResults: 100,
        showCompleted: req.query.showCompleted !== 'false',
      });
      const items = (tasksResult.data.items || []).map(t => ({
        id: t.id,
        title: t.title,
        notes: t.notes || null,
        status: t.status,
        due: t.due || null,
        completed: t.completed || null,
        updated: t.updated,
        listId: list.id,
        listTitle: list.title,
      }));
      allTasks.push(...items);
    }
    res.json({ totalLists: lists.length, totalTasks: allTasks.length, tasks: allTasks });
  } catch (e) {
    if (e.message.includes('insufficient')) {
      return res.status(403).json({ error: 'Tasks scope not authorized. Visit /auth to grant permission.', authUrl: '/auth' });
    }
    res.status(500).json({ error: e.message });
  }
});

// ─── Info@ Tasks API ───
app.get('/api/info/tasks/lists', async (req, res) => {
  try {
    if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized — authorize at /auth/info', authUrl: '/api/auth-url/info' });
    const result = await infoTasksApi.tasklists.list({ maxResults: 100 });
    res.json({ account: 'info@designerwallcoverings.com', lists: result.data.items || [] });
  } catch (e) {
    if (e.message.includes('insufficient')) {
      return res.status(403).json({ error: 'Info@ tasks scope not authorized. Visit /auth/info.', authUrl: '/auth/info' });
    }
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/info/tasks/:listId', async (req, res) => {
  try {
    if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized — authorize at /auth/info', authUrl: '/api/auth-url/info' });
    const showCompleted = req.query.showCompleted !== 'false';
    const showHidden = req.query.showHidden === 'true';
    const result = await infoTasksApi.tasks.list({
      tasklist: req.params.listId,
      maxResults: parseInt(req.query.maxResults) || 100,
      showCompleted,
      showHidden,
    });
    const tasks = (result.data.items || []).map(t => ({
      id: t.id,
      title: t.title,
      notes: t.notes || null,
      status: t.status,
      due: t.due || null,
      completed: t.completed || null,
      updated: t.updated,
      parent: t.parent || null,
      position: t.position,
      links: t.links || [],
    }));
    res.json({ account: 'info@designerwallcoverings.com', listId: req.params.listId, count: tasks.length, tasks });
  } catch (e) {
    if (e.message.includes('insufficient')) {
      return res.status(403).json({ error: 'Info@ tasks scope not authorized.', authUrl: '/auth/info' });
    }
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/info/tasks', async (req, res) => {
  try {
    if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized — authorize at /auth/info', authUrl: '/api/auth-url/info' });
    const listsResult = await infoTasksApi.tasklists.list({ maxResults: 100 });
    const lists = listsResult.data.items || [];
    const allTasks = [];
    for (const list of lists) {
      const tasksResult = await infoTasksApi.tasks.list({
        tasklist: list.id,
        maxResults: 100,
        showCompleted: req.query.showCompleted !== 'false',
      });
      const items = (tasksResult.data.items || []).map(t => ({
        id: t.id,
        title: t.title,
        notes: t.notes || null,
        status: t.status,
        due: t.due || null,
        completed: t.completed || null,
        updated: t.updated,
        listId: list.id,
        listTitle: list.title,
      }));
      allTasks.push(...items);
    }
    res.json({ account: 'info@designerwallcoverings.com', totalLists: lists.length, totalTasks: allTasks.length, tasks: allTasks });
  } catch (e) {
    if (e.message.includes('insufficient')) {
      return res.status(403).json({ error: 'Info@ tasks scope not authorized.', authUrl: '/auth/info' });
    }
    res.status(500).json({ error: e.message });
  }
});

// ─── Info@ Tasks Write Endpoints ───
// Create a new task list
app.post('/api/info/tasks/lists/create', async (req, res) => {
  try {
    if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized', authUrl: '/auth/info' });
    const { title } = req.body;
    if (!title) return res.status(400).json({ error: 'title is required' });
    const result = await infoTasksApi.tasklists.insert({ requestBody: { title } });
    res.json({ success: true, list: result.data });
  } catch (e) {
    if (e.message.includes('insufficient')) {
      return res.status(403).json({ error: 'Tasks write scope not authorized. Visit /auth/info to upgrade from tasks.readonly to tasks.', authUrl: '/auth/info' });
    }
    res.status(500).json({ error: e.message });
  }
});

// Find a task list by name, or create it if it doesn't exist
app.post('/api/info/tasks/lists/find-or-create', async (req, res) => {
  try {
    if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized', authUrl: '/auth/info' });
    const { title } = req.body;
    if (!title) return res.status(400).json({ error: 'title is required' });

    // Search existing lists
    const listsResult = await infoTasksApi.tasklists.list({ maxResults: 100 });
    const existing = (listsResult.data.items || []).find(l => l.title === title);
    if (existing) {
      return res.json({ success: true, created: false, list: existing });
    }

    // Create new list
    const result = await infoTasksApi.tasklists.insert({ requestBody: { title } });
    res.json({ success: true, created: true, list: result.data });
  } catch (e) {
    if (e.message.includes('insufficient')) {
      return res.status(403).json({ error: 'Tasks write scope not authorized. Visit /auth/info.', authUrl: '/auth/info' });
    }
    res.status(500).json({ error: e.message });
  }
});

// Move a task from one list to another (insert into dest + delete from source)
// Google Tasks API has no cross-list move — we must copy then delete
app.post('/api/info/tasks/move', async (req, res) => {
  try {
    if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized', authUrl: '/auth/info' });
    const { sourceListId, taskId, destListId, markCompleted } = req.body;
    if (!sourceListId || !taskId || !destListId) {
      return res.status(400).json({ error: 'sourceListId, taskId, and destListId are required' });
    }

    // 1. Get the task from source list
    const taskResult = await infoTasksApi.tasks.get({ tasklist: sourceListId, task: taskId });
    const task = taskResult.data;

    // 2. Insert into destination list (preserve title, notes, due, links)
    const newTaskBody = {
      title: task.title,
      notes: task.notes || undefined,
      due: task.due || undefined,
      status: markCompleted ? 'completed' : 'needsAction',
    };
    const insertResult = await infoTasksApi.tasks.insert({ tasklist: destListId, requestBody: newTaskBody });

    // 3. Delete from source list
    await infoTasksApi.tasks.delete({ tasklist: sourceListId, task: taskId });

    res.json({
      success: true,
      moved: true,
      sourceListId,
      destListId,
      originalTaskId: taskId,
      newTaskId: insertResult.data.id,
      title: task.title,
    });
  } catch (e) {
    if (e.message.includes('insufficient')) {
      return res.status(403).json({ error: 'Tasks write scope not authorized. Visit /auth/info.', authUrl: '/auth/info' });
    }
    res.status(500).json({ error: e.message });
  }
});

// Update a task's title (e.g., prepend "READY" status)
app.post('/api/info/tasks/:listId/:taskId/update', async (req, res) => {
  try {
    if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized', authUrl: '/auth/info' });
    const { listId, taskId } = req.params;
    const { title, notes } = req.body;
    const body = {};
    if (title) body.title = title;
    if (notes !== undefined) body.notes = notes;
    if (!Object.keys(body).length) return res.status(400).json({ error: 'title or notes required' });
    const result = await infoTasksApi.tasks.patch({
      tasklist: listId,
      task: taskId,
      requestBody: body
    });
    res.json({ success: true, task: result.data });
  } catch (e) {
    if (e.message.includes('insufficient')) {
      return res.status(403).json({ error: 'Tasks write scope not authorized. Visit /auth/info.', authUrl: '/auth/info' });
    }
    res.status(500).json({ error: e.message });
  }
});

// Complete a task (mark as done without moving)
app.post('/api/info/tasks/:listId/:taskId/complete', async (req, res) => {
  try {
    if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized', authUrl: '/auth/info' });
    const { listId, taskId } = req.params;
    const result = await infoTasksApi.tasks.patch({
      tasklist: listId,
      task: taskId,
      requestBody: { status: 'completed' }
    });
    res.json({ success: true, task: result.data });
  } catch (e) {
    if (e.message.includes('insufficient')) {
      return res.status(403).json({ error: 'Tasks write scope not authorized. Visit /auth/info.', authUrl: '/auth/info' });
    }
    res.status(500).json({ error: e.message });
  }
});

// ─── Info@ Gmail Endpoints ───
app.get('/api/info/profile', async (req, res) => {
  try {
    if (!infoGmail) return res.status(503).json({ error: 'Info@ Gmail not initialized — authorize at /auth/info', authUrl: '/api/auth-url/info' });
    const profile = await infoGmail.users.getProfile({ userId: 'me' });
    res.json({ account: 'info@designerwallcoverings.com', ...profile.data });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/info/messages', async (req, res) => {
  try {
    if (!infoGmail) return res.status(503).json({ error: 'Info@ Gmail not initialized — authorize at /auth/info', authUrl: '/api/auth-url/info' });
    const { q, maxResults, labelIds, pageToken } = req.query;
    const params = { userId: 'me', maxResults: parseInt(maxResults) || 20 };
    if (q) params.q = q;
    if (labelIds) params.labelIds = labelIds.split(',');
    if (pageToken) params.pageToken = pageToken;

    const list = await infoGmail.users.messages.list(params);
    const messages = list.data.messages || [];
    const detailed = await Promise.all(
      messages.slice(0, parseInt(maxResults) || 20).map(async m => {
        try {
          const full = await infoGmail.users.messages.get({ userId: 'me', id: m.id, format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date', 'To'] });
          return {
            id: full.data.id,
            threadId: full.data.threadId,
            snippet: full.data.snippet,
            subject: getHeader(full.data.payload?.headers, 'Subject'),
            from: getHeader(full.data.payload?.headers, 'From'),
            to: getHeader(full.data.payload?.headers, 'To'),
            date: getHeader(full.data.payload?.headers, 'Date'),
          };
        } catch { return { id: m.id, error: 'fetch-failed' }; }
      })
    );
    res.json({ account: 'info@designerwallcoverings.com', messages: detailed, nextPageToken: list.data.nextPageToken });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Info@ Google Drive Endpoints ───
const DRIVE_NOT_READY = { error: 'Info@ Drive not initialized — authorize at /auth/info (needs drive.readonly scope)', authUrl: '/auth/info' };

// List files (supports Drive query syntax in ?q= param)
app.get('/api/info/drive', async (req, res) => {
  try {
    if (!infoDrive) return res.status(503).json(DRIVE_NOT_READY);
    const { q, maxResults, pageToken, orderBy } = req.query;
    const params = {
      pageSize: parseInt(maxResults) || 25,
      fields: 'nextPageToken, files(id, name, mimeType, size, modifiedTime, createdTime, webViewLink, iconLink, parents, shared)',
      orderBy: orderBy || 'modifiedTime desc',
    };
    if (q) params.q = q;
    if (pageToken) params.pageToken = pageToken;
    const result = await infoDrive.files.list(params);
    res.json({ account: 'info@', files: result.data.files || [], nextPageToken: result.data.nextPageToken });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// Get file metadata by ID
app.get('/api/info/drive/file/:fileId', async (req, res) => {
  try {
    if (!infoDrive) return res.status(503).json(DRIVE_NOT_READY);
    const result = await infoDrive.files.get({
      fileId: req.params.fileId,
      fields: 'id, name, mimeType, size, modifiedTime, createdTime, webViewLink, webContentLink, iconLink, parents, shared, description',
    });
    res.json(result.data);
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// Search files by name
app.get('/api/info/drive/search/:query', async (req, res) => {
  try {
    if (!infoDrive) return res.status(503).json(DRIVE_NOT_READY);
    const searchQ = `name contains '${req.params.query.replace(/'/g, "\\'")}'`;
    const result = await infoDrive.files.list({
      q: searchQ,
      pageSize: parseInt(req.query.maxResults) || 25,
      fields: 'files(id, name, mimeType, size, modifiedTime, webViewLink, parents)',
      orderBy: 'modifiedTime desc',
    });
    res.json({ account: 'info@', query: req.params.query, files: result.data.files || [] });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// List shared drives
app.get('/api/info/drive/shared', async (req, res) => {
  try {
    if (!infoDrive) return res.status(503).json(DRIVE_NOT_READY);
    const result = await infoDrive.drives.list({ pageSize: 50 });
    res.json({ account: 'info@', drives: result.data.drives || [] });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Info@ Single Message (with HTML body) ───
function decodeHtmlBody(parts) {
  if (!parts) return { html: '', text: '' };
  let html = '', text = '';
  for (const part of parts) {
    if (part.mimeType === 'text/html' && part.body?.data) {
      html = Buffer.from(part.body.data, 'base64url').toString('utf8');
    }
    if (part.mimeType === 'text/plain' && part.body?.data) {
      text = Buffer.from(part.body.data, 'base64url').toString('utf8');
    }
    if (part.parts) {
      const nested = decodeHtmlBody(part.parts);
      if (nested.html && !html) html = nested.html;
      if (nested.text && !text) text = nested.text;
    }
  }
  return { html, text };
}

app.get('/api/info/messages/:id', async (req, res) => {
  try {
    if (!infoGmail) return res.status(503).json({ error: 'Info@ Gmail not initialized', authUrl: '/auth/info' });
    const msg = await infoGmail.users.messages.get({ userId: 'me', id: req.params.id, format: 'full' });
    const headers = msg.data.payload?.headers || [];

    // Decode both HTML and text bodies
    const topBody = msg.data.payload?.body?.data
      ? Buffer.from(msg.data.payload.body.data, 'base64url').toString('utf8')
      : null;
    const { html, text } = decodeHtmlBody(msg.data.payload?.parts);

    const attachments = [];
    function findAttachments(parts) {
      if (!parts) return;
      for (const p of parts) {
        if (p.filename && p.body?.attachmentId) {
          attachments.push({ filename: p.filename, mimeType: p.mimeType, size: p.body.size, attachmentId: p.body.attachmentId });
        }
        if (p.parts) findAttachments(p.parts);
      }
    }
    findAttachments(msg.data.payload?.parts);

    res.json({
      id: msg.data.id,
      threadId: msg.data.threadId,
      labelIds: msg.data.labelIds,
      snippet: msg.data.snippet,
      subject: getHeader(headers, 'Subject'),
      from: getHeader(headers, 'From'),
      to: getHeader(headers, 'To'),
      cc: getHeader(headers, 'Cc'),
      date: getHeader(headers, 'Date'),
      bodyHtml: html || topBody || '',
      bodyText: text || topBody || '',
      attachments,
      internalDate: msg.data.internalDate
    });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Info@ Search Messages ───
app.get('/api/info/search', async (req, res) => {
  try {
    if (!infoGmail) return res.status(503).json({ error: 'Info@ Gmail not initialized', authUrl: '/auth/info' });
    const { q, maxResults } = req.query;
    if (!q) return res.status(400).json({ error: 'Query (q) required' });
    const list = await infoGmail.users.messages.list({ userId: 'me', q, maxResults: parseInt(maxResults) || 10 });
    const messages = list.data.messages || [];

    const detailed = await Promise.all(
      messages.map(async m => {
        try {
          const full = await infoGmail.users.messages.get({ userId: 'me', id: m.id, format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date', 'To'] });
          return {
            id: full.data.id,
            snippet: full.data.snippet,
            subject: getHeader(full.data.payload?.headers, 'Subject'),
            from: getHeader(full.data.payload?.headers, 'From'),
            to: getHeader(full.data.payload?.headers, 'To'),
            date: getHeader(full.data.payload?.headers, 'Date')
          };
        } catch { return { id: m.id, error: 'fetch-failed' }; }
      })
    );

    res.json({ account: 'info@designerwallcoverings.com', query: q, messages: detailed, total: list.data.resultSizeEstimate });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Info@ Download Attachment ───
app.get('/api/info/messages/:messageId/attachments/:attachmentId', async (req, res) => {
  try {
    if (!infoGmail) return res.status(503).json({ error: 'Info@ Gmail not initialized', authUrl: '/auth/info' });
    const att = await infoGmail.users.messages.attachments.get({
      userId: 'me', messageId: req.params.messageId, id: req.params.attachmentId
    });
    const buf = Buffer.from(att.data.data, 'base64');
    res.set('Content-Length', buf.length);
    res.send(buf);
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ══════════════════════════════════════════════════════════════
// ─── Unified Google Workspace API (per-account) ───
// Endpoints below accept ?account=steve-office|info|steve-personal
// Default account = steve-office.
// ══════════════════════════════════════════════════════════════

function requireService(svc, svcName, account, res) {
  if (!svc) {
    res.status(503).json({ error: `${svcName} not ready for account '${account}' — authorize via /auth/${account}` });
    return false;
  }
  return true;
}

// ─── Google Docs ───
app.post('/api/docs/create', async (req, res) => {
  try {
    const { docs: d, key, label } = resolveAccount(req);
    if (!requireService(d, 'Docs', key, res)) return;
    const { title = 'Untitled' } = req.body || {};
    const result = await d.documents.create({ requestBody: { title } });
    audit(key, 'docs.create', { documentId: result.data.documentId, title });
    res.json({ account: key, email: label, documentId: result.data.documentId, title, url: `https://docs.google.com/document/d/${result.data.documentId}/edit` });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.get('/api/docs/:id', async (req, res) => {
  try {
    const { docs: d, key, label } = resolveAccount(req);
    if (!requireService(d, 'Docs', key, res)) return;
    const result = await d.documents.get({ documentId: req.params.id });
    res.json({ account: key, email: label, document: result.data });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.post('/api/docs/:id/append', async (req, res) => {
  try {
    const { docs: d, key } = resolveAccount(req);
    if (!requireService(d, 'Docs', key, res)) return;
    const { text = '' } = req.body || {};
    // Get end index
    const meta = await d.documents.get({ documentId: req.params.id });
    const endIndex = meta.data.body?.content?.slice(-1)[0]?.endIndex || 1;
    await d.documents.batchUpdate({
      documentId: req.params.id,
      requestBody: { requests: [{ insertText: { location: { index: Math.max(endIndex - 1, 1) }, text } }] }
    });
    audit(key, 'docs.append', { documentId: req.params.id, length: text.length });
    res.json({ account: key, documentId: req.params.id, appendedChars: text.length });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.post('/api/docs/:id/replace', async (req, res) => {
  try {
    const { docs: d, key } = resolveAccount(req);
    if (!requireService(d, 'Docs', key, res)) return;
    const { text = '' } = req.body || {};
    // Wipe body then insert — delete range then insertText
    const meta = await d.documents.get({ documentId: req.params.id });
    const endIndex = meta.data.body?.content?.slice(-1)[0]?.endIndex || 1;
    const requests = [];
    if (endIndex > 2) {
      requests.push({ deleteContentRange: { range: { startIndex: 1, endIndex: endIndex - 1 } } });
    }
    if (text) requests.push({ insertText: { location: { index: 1 }, text } });
    if (requests.length) await d.documents.batchUpdate({ documentId: req.params.id, requestBody: { requests } });
    audit(key, 'docs.replace', { documentId: req.params.id, length: text.length });
    res.json({ account: key, documentId: req.params.id, length: text.length });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// ─── Markdown → Google Docs formatter ───
// Parses a subset of Markdown (# headings, - bullets, > blockquotes, **bold**,
// `code`, [link](url), ---) into Google Docs batchUpdate requests.
function renderMarkdownToDocs(md) {
  const lines = md.split('\n');
  let text = '';
  const paraRanges = [];
  const textRanges = [];
  let cursor = 1; // Docs indexing starts at 1

  for (const raw of lines) {
    let content = raw;
    let namedStyleType = null;
    let isBullet = false;
    let isQuote = false;

    if (raw.startsWith('### ')) { content = raw.slice(4); namedStyleType = 'HEADING_3'; }
    else if (raw.startsWith('## ')) { content = raw.slice(3); namedStyleType = 'HEADING_2'; }
    else if (raw.startsWith('# ')) { content = raw.slice(2); namedStyleType = 'HEADING_1'; }
    else if (raw.startsWith('- ') || raw.startsWith('* ')) { content = raw.slice(2); isBullet = true; }
    else if (raw.startsWith('> ')) { content = raw.slice(2); isQuote = true; }
    else if (raw === '---' || raw === '***' || raw === '___') { content = ''; }

    // Inline parse: **bold**, `code`, [text](url)
    let stripped = '';
    let i = 0;
    while (i < content.length) {
      if (content.slice(i, i + 2) === '**') {
        const end = content.indexOf('**', i + 2);
        if (end > 0) {
          const inner = content.slice(i + 2, end);
          textRanges.push({ start: cursor + stripped.length, end: cursor + stripped.length + inner.length, bold: true });
          stripped += inner; i = end + 2; continue;
        }
      }
      if (content[i] === '`') {
        const end = content.indexOf('`', i + 1);
        if (end > i) {
          const inner = content.slice(i + 1, end);
          textRanges.push({ start: cursor + stripped.length, end: cursor + stripped.length + inner.length, code: true });
          stripped += inner; i = end + 1; continue;
        }
      }
      if (content[i] === '[') {
        const rb = content.indexOf(']', i + 1);
        if (rb > 0 && content[rb + 1] === '(') {
          const rp = content.indexOf(')', rb + 1);
          if (rp > 0) {
            const linkText = content.slice(i + 1, rb);
            const url = content.slice(rb + 2, rp);
            textRanges.push({ start: cursor + stripped.length, end: cursor + stripped.length + linkText.length, link: url });
            stripped += linkText; i = rp + 1; continue;
          }
        }
      }
      stripped += content[i]; i++;
    }

    const lineStart = cursor;
    text += stripped + '\n';
    const lineEnd = cursor + stripped.length + 1;

    if (namedStyleType) paraRanges.push({ start: lineStart, end: lineEnd, namedStyleType });
    else if (isBullet) paraRanges.push({ start: lineStart, end: lineEnd, isBullet: true });
    else if (isQuote) paraRanges.push({ start: lineStart, end: lineEnd, isQuote: true });

    cursor = lineEnd;
  }

  // Build batchUpdate request set (order matters: insert first, then styles)
  const requests = [];
  requests.push({ insertText: { location: { index: 1 }, text } });

  // Paragraph styles: headings + quotes
  for (const p of paraRanges) {
    if (p.namedStyleType) {
      requests.push({
        updateParagraphStyle: {
          range: { startIndex: p.start, endIndex: p.end },
          paragraphStyle: { namedStyleType: p.namedStyleType },
          fields: 'namedStyleType'
        }
      });
    } else if (p.isQuote) {
      requests.push({
        updateParagraphStyle: {
          range: { startIndex: p.start, endIndex: p.end },
          paragraphStyle: { indentStart: { magnitude: 36, unit: 'PT' }, namedStyleType: 'NORMAL_TEXT' },
          fields: 'indentStart,namedStyleType'
        }
      });
      if (p.end - 1 > p.start) {
        requests.push({
          updateTextStyle: {
            range: { startIndex: p.start, endIndex: p.end - 1 },
            textStyle: { italic: true, foregroundColor: { color: { rgbColor: { red: 0.3, green: 0.3, blue: 0.3 } } } },
            fields: 'italic,foregroundColor'
          }
        });
      }
    }
  }

  // Bullets: group contiguous bullet paragraphs into one createParagraphBullets call
  const bullets = paraRanges.filter(p => p.isBullet);
  for (let i = 0; i < bullets.length;) {
    let j = i;
    while (j < bullets.length - 1 && bullets[j + 1].start === bullets[j].end) j++;
    requests.push({
      createParagraphBullets: {
        range: { startIndex: bullets[i].start, endIndex: bullets[j].end },
        bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE'
      }
    });
    i = j + 1;
  }

  // Inline text styles: bold, code, links
  for (const t of textRanges) {
    if (t.end <= t.start) continue;
    const textStyle = {};
    const fields = [];
    if (t.bold) { textStyle.bold = true; fields.push('bold'); }
    if (t.code) {
      textStyle.weightedFontFamily = { fontFamily: 'Roboto Mono', weight: 400 };
      textStyle.backgroundColor = { color: { rgbColor: { red: 0.96, green: 0.96, blue: 0.96 } } };
      fields.push('weightedFontFamily', 'backgroundColor');
    }
    if (t.link) {
      textStyle.link = { url: t.link };
      textStyle.foregroundColor = { color: { rgbColor: { red: 0.06, green: 0.43, blue: 0.56 } } };
      textStyle.underline = true;
      fields.push('link', 'foregroundColor', 'underline');
    }
    if (!fields.length) continue;
    requests.push({
      updateTextStyle: {
        range: { startIndex: t.start, endIndex: t.end },
        textStyle,
        fields: fields.join(',')
      }
    });
  }

  return { text, requests };
}

app.post('/api/docs/:id/replace-markdown', async (req, res) => {
  try {
    const { docs: d, key } = resolveAccount(req);
    if (!requireService(d, 'Docs', key, res)) return;
    const { markdown = '' } = req.body || {};

    // Step 1: wipe existing doc content
    const meta = await d.documents.get({ documentId: req.params.id });
    const endIndex = meta.data.body?.content?.slice(-1)[0]?.endIndex || 1;
    if (endIndex > 2) {
      await d.documents.batchUpdate({
        documentId: req.params.id,
        requestBody: { requests: [{ deleteContentRange: { range: { startIndex: 1, endIndex: endIndex - 1 } } }] }
      });
    }

    // Step 2: render markdown → batchUpdate requests
    const { text, requests } = renderMarkdownToDocs(markdown);

    // Step 3: apply in chunks of 500 (Docs API limit is ~1000 per batch)
    const CHUNK = 500;
    let applied = 0;
    for (let i = 0; i < requests.length; i += CHUNK) {
      await d.documents.batchUpdate({
        documentId: req.params.id,
        requestBody: { requests: requests.slice(i, i + CHUNK) }
      });
      applied += Math.min(CHUNK, requests.length - i);
    }

    audit(key, 'docs.replace-markdown', { documentId: req.params.id, markdownLength: markdown.length, requests: requests.length });
    res.json({
      account: key,
      documentId: req.params.id,
      url: `https://docs.google.com/document/d/${req.params.id}/edit`,
      markdownLength: markdown.length,
      textLength: text.length,
      requestsApplied: applied
    });
  } catch (e) {
    res.status(500).json({ error: e.message, detail: (e.errors || []).slice(0, 3) });
  }
});

// ─── Google Sheets ───
app.post('/api/sheets/create', async (req, res) => {
  try {
    const { sheets: s, key, label } = resolveAccount(req);
    if (!requireService(s, 'Sheets', key, res)) return;
    const { title = 'Untitled' } = req.body || {};
    const result = await s.spreadsheets.create({ requestBody: { properties: { title } } });
    audit(key, 'sheets.create', { spreadsheetId: result.data.spreadsheetId, title });
    res.json({ account: key, email: label, spreadsheetId: result.data.spreadsheetId, title, url: result.data.spreadsheetUrl });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.get('/api/sheets/:id', async (req, res) => {
  try {
    const { sheets: s, key } = resolveAccount(req);
    if (!requireService(s, 'Sheets', key, res)) return;
    const result = await s.spreadsheets.get({ spreadsheetId: req.params.id });
    res.json({ account: key, spreadsheet: result.data });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.get('/api/sheets/:id/values', async (req, res) => {
  try {
    const { sheets: s, key } = resolveAccount(req);
    if (!requireService(s, 'Sheets', key, res)) return;
    const range = req.query.range || 'Sheet1';
    const result = await s.spreadsheets.values.get({ spreadsheetId: req.params.id, range });
    res.json({ account: key, range, values: result.data.values || [] });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.post('/api/sheets/:id/append', async (req, res) => {
  try {
    const { sheets: s, key } = resolveAccount(req);
    if (!requireService(s, 'Sheets', key, res)) return;
    const { range = 'Sheet1', values = [] } = req.body || {};
    const result = await s.spreadsheets.values.append({
      spreadsheetId: req.params.id, range, valueInputOption: 'USER_ENTERED',
      requestBody: { values }
    });
    audit(key, 'sheets.append', { spreadsheetId: req.params.id, rows: values.length });
    res.json({ account: key, updates: result.data.updates });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// ─── Google Slides ───
app.post('/api/slides/create', async (req, res) => {
  try {
    const { slides: sl, key, label } = resolveAccount(req);
    if (!requireService(sl, 'Slides', key, res)) return;
    const { title = 'Untitled' } = req.body || {};
    const result = await sl.presentations.create({ requestBody: { title } });
    audit(key, 'slides.create', { presentationId: result.data.presentationId, title });
    res.json({ account: key, email: label, presentationId: result.data.presentationId, title, url: `https://docs.google.com/presentation/d/${result.data.presentationId}/edit` });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.get('/api/slides/:id', async (req, res) => {
  try {
    const { slides: sl, key } = resolveAccount(req);
    if (!requireService(sl, 'Slides', key, res)) return;
    const result = await sl.presentations.get({ presentationId: req.params.id });
    res.json({ account: key, presentation: result.data });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// ─── Google Drive (per-account) ───
app.get('/api/drive/list', async (req, res) => {
  try {
    const { drive: dr, key } = resolveAccount(req);
    if (!requireService(dr, 'Drive', key, res)) return;
    const result = await dr.files.list({
      pageSize: parseInt(req.query.pageSize) || 25,
      fields: 'files(id, name, mimeType, size, modifiedTime, webViewLink)',
      orderBy: 'modifiedTime desc',
      q: req.query.q || undefined,
    });
    res.json({ account: key, files: result.data.files || [] });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.get('/api/drive/file/:id', async (req, res) => {
  try {
    const { drive: dr, key } = resolveAccount(req);
    if (!requireService(dr, 'Drive', key, res)) return;
    const result = await dr.files.get({
      fileId: req.params.id,
      fields: 'id, name, mimeType, size, modifiedTime, createdTime, webViewLink, parents, shared'
    });
    res.json({ account: key, file: result.data });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// Download raw file bytes (alt=media). Streams directly to the response.
// Used by the AnimalsDirectory pet-photo importer to pull images without
// having to expose the user's OAuth token externally.
app.get('/api/drive/download/:id', async (req, res) => {
  try {
    const { drive: dr, key } = resolveAccount(req);
    if (!requireService(dr, 'Drive', key, res)) return;
    // First metadata call so we can set Content-Type + Content-Disposition correctly
    const meta = await dr.files.get({ fileId: req.params.id, fields: 'name, mimeType, size' });
    const stream = await dr.files.get(
      { fileId: req.params.id, alt: 'media' },
      { responseType: 'stream' }
    );
    res.setHeader('Content-Type', meta.data.mimeType || 'application/octet-stream');
    if (meta.data.size) res.setHeader('Content-Length', meta.data.size);
    res.setHeader('Content-Disposition', `inline; filename="${(meta.data.name || req.params.id).replace(/"/g, '')}"`);
    stream.data.on('error', err => { audit(key, 'drive.download.error', { fileId: req.params.id, err: err.message }); });
    stream.data.pipe(res);
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.post('/api/drive/upload', async (req, res) => {
  try {
    const { drive: dr, key } = resolveAccount(req);
    if (!requireService(dr, 'Drive', key, res)) return;
    const { name, mimeType = 'text/plain', content = '', parents } = req.body || {};
    if (!name) return res.status(400).json({ error: 'name required' });
    const result = await dr.files.create({
      requestBody: { name, parents },
      media: { mimeType, body: content },
      fields: 'id, name, mimeType, webViewLink'
    });
    audit(key, 'drive.upload', { fileId: result.data.id, name });
    res.json({ account: key, file: result.data });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// ─── Google Calendar (per-account) ───
app.get('/api/calendar/events', async (req, res) => {
  try {
    const { calendar: c, key } = resolveAccount(req);
    if (!requireService(c, 'Calendar', key, res)) return;
    const calendarId = req.query.calendarId || 'primary';
    const timeMin = req.query.from || new Date().toISOString();
    const timeMax = req.query.to;
    const result = await c.events.list({
      calendarId, timeMin, timeMax, singleEvents: true, orderBy: 'startTime',
      maxResults: parseInt(req.query.maxResults) || 50
    });
    res.json({ account: key, calendarId, events: result.data.items || [] });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.post('/api/calendar/events', async (req, res) => {
  try {
    const { calendar: c, key } = resolveAccount(req);
    if (!requireService(c, 'Calendar', key, res)) return;
    const { calendarId = 'primary', event } = req.body || {};
    if (!event) return res.status(400).json({ error: 'event body required' });
    const result = await c.events.insert({ calendarId, requestBody: event });
    audit(key, 'calendar.event.create', { eventId: result.data.id, summary: event.summary });
    res.json({ account: key, event: result.data });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// ─── Health Check (updated for 3 accounts) ───
app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    agent: 'George',
    port: PORT,
    uptime: process.uptime(),
    scopes: FULL_WORKSPACE_SCOPES,
    accounts: {
      'steve-office': {
        ready: !!gmail,
        email: 'steve@designerwallcoverings.com',
        services: { gmail: !!gmail, tasks: !!tasksApi, drive: !!drive, docs: !!docs, sheets: !!sheets, slides: !!slides, calendar: !!calendar, forms: !!forms },
        authUrl: gmail ? null : '/auth/steve-office'
      },
      'info': {
        ready: !!infoGmail,
        email: 'info@designerwallcoverings.com',
        services: { gmail: !!infoGmail, tasks: !!infoTasksApi, drive: !!infoDrive, docs: !!infoDocs, sheets: !!infoSheets, slides: !!infoSlides, calendar: !!infoCalendar, forms: !!infoForms },
        authUrl: infoGmail ? null : '/auth/info'
      },
      'steve-personal': {
        ready: !!personalGmail,
        email: 'steveabramsdesigns@gmail.com',
        services: { gmail: !!personalGmail, tasks: !!personalTasksApi, drive: !!personalDrive, docs: !!personalDocs, sheets: !!personalSheets, slides: !!personalSlides, calendar: !!personalCalendar, forms: !!personalForms },
        authUrl: personalGmail ? null : '/auth/steve-personal'
      }
    }
  });
});

// ─── Start ───
app.listen(PORT, () => {
  console.log(`[${AGENT_NAME}] Gmail Agent running on port ${PORT}`);
  console.log(`[${AGENT_NAME}] Dashboard: http://45.61.58.125:${PORT}/`);
  console.log(`[${AGENT_NAME}] API: /api/messages, /api/search, /api/send, /api/labels, /api/profile`);
});