← back to George Gmail
server.js
2817 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 { buildAttachmentMime } = require('./lib/attachment-mime');
const { sendPreflight } = require('./lib/send-preflight');
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';
// ─── Calendar re-consent (GOOGLE_CALENDAR_REFRESH_TOKEN) ───
// Reuses GMAIL_CLIENT_ID/SECRET — token-age-warn.mjs exchanges the calendar
// refresh token with the George client, so it MUST be minted from this client.
const CALENDAR_REDIRECT_URI = 'http://localhost:9850/oauth2callback';
const CALENDAR_REFRESH_TOKEN = creds.GOOGLE_CALENDAR_REFRESH_TOKEN || process.env.GOOGLE_CALENDAR_REFRESH_TOKEN;
// ─── 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)
// Staggered stage 1 (2026-09-01): steve-office ONLY moves to the new Internal
// OAuth client (project 492000337767) so its next re-consent mints a durable
// token. Falls back to the existing client when the Internal creds are absent.
// info/personal/agentabrams/calendar stay on GMAIL_CLIENT_ID/SECRET untouched.
const SO_ID = process.env.GMAIL_INTERNAL_CLIENT_ID || creds.GMAIL_INTERNAL_CLIENT_ID || GMAIL_CLIENT_ID;
const SO_SECRET = process.env.GMAIL_INTERNAL_CLIENT_SECRET || creds.GMAIL_INTERNAL_CLIENT_SECRET || GMAIL_CLIENT_SECRET;
let oauth2Client;
let gmail;
let tasksApi;
try {
oauth2Client = new google.auth.OAuth2(SO_ID, SO_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)
// Staggered stage 2 (2026-09-01): info@ moves to the new Internal OAuth client
// (project 964770033153, george-workspace-internal) so its next re-consent mints
// a durable token. Falls back to the existing client when Internal creds absent.
// Both /auth/info (mint) + /oauth2callback info branch (exchange) reference this
// same infoOauth2Client, so the mint/exchange-must-match invariant holds.
const INFO_ID = process.env.GMAIL_INTERNAL_CLIENT_ID || creds.GMAIL_INTERNAL_CLIENT_ID || GMAIL_CLIENT_ID;
const INFO_SECRET = process.env.GMAIL_INTERNAL_CLIENT_SECRET || creds.GMAIL_INTERNAL_CLIENT_SECRET || GMAIL_CLIENT_SECRET;
let infoOauth2Client;
let infoGmail;
let infoTasksApi;
let infoDrive;
let infoDocs;
let infoSheets;
let infoSlides;
let infoCalendar;
let infoForms;
try {
infoOauth2Client = new google.auth.OAuth2(INFO_ID, INFO_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}`);
}
// ─── Calendar account (GOOGLE_CALENDAR_REFRESH_TOKEN) ───
let calendarOauth2Client;
let calendarApi;
try {
calendarOauth2Client = new google.auth.OAuth2(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, CALENDAR_REDIRECT_URI);
if (CALENDAR_REFRESH_TOKEN && !/^your-calendar/.test(CALENDAR_REFRESH_TOKEN)) {
calendarOauth2Client.setCredentials({ refresh_token: CALENDAR_REFRESH_TOKEN });
calendarApi = google.calendar({ version: 'v3', auth: calendarOauth2Client });
console.log(`[${AGENT_NAME}] Calendar account initialized`);
} else {
console.log(`[${AGENT_NAME}] Calendar not configured — visit /auth/calendar to authorize`);
}
} catch (e) {
console.error(`[${AGENT_NAME}] Calendar 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", "calendar"
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 },
'calendar': { label: 'calendar', oauth: calendarOauth2Client, calendar: calendarApi, refreshToken: CALENDAR_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', '/api/live-check', '/auth', '/auth/info', '/auth/steve-office', '/auth/steve-personal', '/auth/agentabrams', '/auth/calendar', '/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',
'calendar': '/auth/calendar',
};
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);
}
// Route to calendar handler if state=calendar
if (state === 'calendar') {
const { tokens } = await calendarOauth2Client.getToken({ code, redirect_uri: CALENDAR_REDIRECT_URI });
calendarOauth2Client.setCredentials(tokens);
calendarApi = google.calendar({ version: 'v3', auth: calendarOauth2Client });
let msg = '<html><body style="font-family:sans-serif;background:#0f1117;color:#e4e4e7;padding:40px">';
msg += '<h2 style="color:#4ade80">Calendar Authorized!</h2>';
if (tokens.refresh_token) {
msg += `<p><strong>New Calendar 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}] Calendar refresh token obtained: ${tokens.refresh_token.substring(0, 20)}...`);
try {
let envContent = fs.readFileSync(ENV_PATH, 'utf8');
if (envContent.includes('GOOGLE_CALENDAR_REFRESH_TOKEN=')) {
envContent = envContent.replace(/GOOGLE_CALENDAR_REFRESH_TOKEN=.*/, `GOOGLE_CALENDAR_REFRESH_TOKEN=${tokens.refresh_token}`);
} else {
envContent += `\n# Google Calendar OAuth Token\nGOOGLE_CALENDAR_REFRESH_TOKEN=${tokens.refresh_token}\n`;
}
fs.writeFileSync(ENV_PATH, envContent);
msg += '<p style="color:#4ade80">Token auto-saved to central .env!</p>';
audit('calendar', 'oauth-authorized', { scopes: ['https://www.googleapis.com/auth/calendar'].length });
} catch (e) {
msg += `<p style="color:#f87171">Could not auto-save: ${e.message}</p>`;
}
}
try {
const list = await calendarApi.calendarList.list({ maxResults: 1 });
const primary = (list.data.items || [])[0];
if (primary) msg += `<p style="color:#60a5fa">Connected — calendar: <strong>${primary.summary || primary.id}</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);
}
// 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 });
});
// ─── Calendar OAuth Authorization Flow ───
app.get('/auth/calendar', (req, res) => {
if (!calendarOauth2Client) return res.status(500).send('Calendar OAuth client not configured');
const url = calendarOauth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['https://www.googleapis.com/auth/calendar'],
prompt: 'consent',
redirect_uri: CALENDAR_REDIRECT_URI,
state: 'calendar',
});
res.redirect(url);
});
app.get('/api/auth-url/calendar', (req, res) => {
if (!calendarOauth2Client) return res.status(500).json({ error: 'Calendar OAuth client not configured' });
const url = calendarOauth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['https://www.googleapis.com/auth/calendar'],
prompt: 'consent',
redirect_uri: CALENDAR_REDIRECT_URI,
state: 'calendar',
});
res.json({ authUrl: url, account: 'GOOGLE_CALENDAR_REFRESH_TOKEN', scopes: ['https://www.googleapis.com/auth/calendar'] });
});
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', '/api/live-check', '/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 '';
// TK-11365 (2026-09-10): the banner is an INTERNAL provenance aid and must never reach an
// outside party. It was opt-OUT, so any caller that forgot no_source_tag prepended an internal
// job label to vendor/customer mail - proven in prod on a real customer (4 emails opening
// "From job: kelly-reply-tk10830") and found on 14 of 15 queued vendor drafts. The MCP tool
// gmail_create_draft does not even expose no_source_tag, so the opt-out was unreachable from
// the agent path. Now: if ANY recipient is external, no banner. Internal mail keeps it.
try {
const b = body || {};
const ext = _recips(b.to, b.cc, b.bcc).filter(a => !_isInternal(a));
if (ext.length) return '';
} catch (e) { /* helpers defined later in file; fall through to normal behaviour */ }
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) {
// TK-11365 (2026-09-12): Banner suppressed entirely from email body.
// Provenance info (source) is already captured in: audit() calls, response JSON,
// and X-George-Source header. The HTML tag was leaking into vendor/customer mail
// regardless of recipient type because the try/catch in inferSource silently fell
// through and the MCP gmail_create_draft tool never exposed no_source_tag.
// Source value is kept alive in the call chain for audit/header use — body is clean.
return htmlBody || '';
}
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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(', ') };
}
function runSendPreflight(req, payload, expectedFrom, externalRecipients) {
const result = sendPreflight(payload, { expectedFrom, externalRecipients });
const action = result.shouldBlock ? 'send-PREFLIGHT-BLOCKED' : (!result.ok && result.enforcement === 'report-only' ? 'send-PREFLIGHT-REPORT' : null);
if (action) try { audit(req.body?.account || '?', action, { to: payload.to, messageClass: result.messageClass, basis: result.basis, failed: result.failed.map(c => c.id) }); } catch {}
return result;
}
app.post('/api/send-preflight', (req, res) => {
const account = resolveAccount(req);
if (!account.gmail) return res.status(400).json({ error: `unknown account: ${account.key}` });
const external = _recips(req.body?.to, req.body?.cc, req.body?.bcc).filter(a => !_isInternal(a));
const result = sendPreflight(req.body || {}, { expectedFrom: account.label, externalRecipients: external, endpointEvaluation: true });
return res.status(result.ok ? 200 : 422).json(result);
});
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, label: accountLabel } = 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 external = _recips(to, cc, bcc).filter(a => !_isInternal(a));
const preflight = runSendPreflight(req, { ...req.body, subject: finalSubject }, accountLabel, external);
if (preflight.shouldBlock) return res.status(422).json({ error: 'send compliance preflight failed', blocked: true, messageClass: preflight.messageClass, basis: preflight.basis, failed: preflight.failed, checks: preflight.checks });
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}` });
}
const accountInfo = resolveAccount(req);
const external = _recips(to, cc, bcc).filter(a => !_isInternal(a));
const preflight = runSendPreflight(req, req.body, accountInfo.label, external);
if (preflight.shouldBlock) return res.status(422).json({ error: 'send compliance preflight failed', blocked: true, messageClass: preflight.messageClass, basis: preflight.basis, failed: preflight.failed, checks: preflight.checks });
// 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 buildAttachmentMime(mailOpts);
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 ───
// Supports optional `labelIds` array — applied via messages.modify after draft creation
// since the Gmail drafts.create raw-message path doesn't surface labelIds directly.
// Example: POST /api/drafts?account=info body: { to, subject, body, labelIds: ["Label_12345"] }
app.post('/api/drafts', async (req, res) => {
try {
const { to, subject, body, cc, bcc, labelIds } = 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}` });
// ─── Duplicate detection at the SHARED layer (TK-11231) ───
// The MCP wrapper refuses duplicates, but that only protects ONE client: anything
// POSTing here directly (cron scripts, other repos, Kamatera) walks straight past it.
// MODE via GEORGE_DRAFT_DEDUPE: 'off' (default) | 'warn' (report) | 'enforce' (409).
// Default is OFF ON PURPOSE: this costs one extra Gmail search PER DRAFT, and the bulk
// callers here (sample-followup drafts many vendors in a burst) are exactly the traffic
// that already exhausted the per-minute quota once. Flip to warn/enforce deliberately.
const dedupeMode = process.env.GEORGE_DRAFT_DEDUPE || 'off';
let duplicatesToRecipient = null;
if (dedupeMode !== 'off' && to && !(req.body.acknowledge_existing)) {
try {
const found = await g.users.messages.list({
userId: 'me', q: `in:draft to:${to}`, maxResults: 10,
});
const hits = found.data.messages || [];
if (hits.length) {
duplicatesToRecipient = hits.map((m) => m.id);
if (dedupeMode === 'enforce') {
audit(account, 'draft-refused-duplicate', { to, subject, existing: duplicatesToRecipient });
return res.status(409).json({
error: `REFUSED — ${hits.length} unsent draft(s) already addressed to ${to}. ` +
`Revise one (PUT /api/drafts/:id) instead of composing another, ` +
`or resend with acknowledge_existing:true.`,
existing_draft_message_ids: duplicatesToRecipient,
});
}
}
} catch (e) {
// 'warn' is advisory, so a failed check must not block a draft. 'enforce' fails CLOSED:
// silently skipping the check is how a quota blip reopened the duplicate hole once.
if (dedupeMode === 'enforce') {
return res.status(503).json({
error: `REFUSED — could not verify duplicates for ${to} (${e.message}). ` +
`Refusing rather than risk a duplicate; retry, or set acknowledge_existing:true.`,
});
}
duplicatesToRecipient = ['check-failed'];
}
}
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 } } });
const draftId = result.data.id;
const messageId = result.data.message?.id;
// Apply labels if requested (messages.modify is the reliable path for custom labels)
let appliedLabels = [];
if (Array.isArray(labelIds) && labelIds.length > 0 && messageId) {
try {
await g.users.messages.modify({
userId: 'me',
id: messageId,
requestBody: { addLabelIds: labelIds },
});
appliedLabels = labelIds;
} catch (labelErr) {
// Non-fatal: draft was created, just label application failed
console.warn(`[george] draft ${draftId} created but label apply failed: ${labelErr.message}`);
}
}
// Telemetry: without this there is no way to ever learn that the acknowledge_existing
// escape hatch went reflexive, or that duplicates are still being composed.
audit(account, 'draft', {
to, subject, source, draftId, labelIds: appliedLabels,
...(req.body.acknowledge_existing ? { acknowledged_existing: true } : {}),
...(duplicatesToRecipient ? { duplicates_to_recipient: duplicatesToRecipient } : {}),
});
res.json({
success: true, draftId, messageId, account, source, appliedLabels,
// A draft is NOT a delivered outcome — every caller sees this, not just the MCP.
status: 'awaiting_owner_send',
delivered: false,
recipient_has_not_received_this: true,
warning: 'Draft created — NOT SENT. Nobody has received this. Do not report the task ' +
'complete, close a ticket, or record "waiting on the other party".',
...(duplicatesToRecipient ? { duplicate_warning: duplicatesToRecipient } : {}),
});
} 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: Bulk Label Messages/Drafts ───
// POST /api/messages/bulk-label?account=info
// body: { q: "in:drafts subject:\"Sample Follow-Up\"", addLabelIds: ["Label_ID"], removeLabelIds: [] }
// Searches matching messages and applies/removes label IDs via batchModify.
// Safe: read then batch-modify; paginated up to maxPages (default 10 x 500 = 5000 msgs).
// Returns { labeled: N, failed: N, total: N, ids: [...first50] }
app.post('/api/messages/bulk-label', async (req, res) => {
try {
const { q, addLabelIds, removeLabelIds, dryRun } = req.body;
if (!q) return res.status(400).json({ error: 'q (search query) required' });
if (!addLabelIds?.length && !removeLabelIds?.length) {
return res.status(400).json({ error: 'addLabelIds or removeLabelIds required' });
}
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
// Paginate through search results
const PAGE = 500;
const MAX_PAGES = parseInt(req.body.maxPages || '10', 10);
let allIds = [];
let pageToken;
for (let p = 0; p < MAX_PAGES; p++) {
const params = { userId: 'me', q, maxResults: PAGE };
if (pageToken) params.pageToken = pageToken;
const r = await g.users.messages.list(params);
const msgs = r.data.messages || [];
allIds.push(...msgs.map((m) => m.id));
pageToken = r.data.nextPageToken;
if (!pageToken || msgs.length < PAGE) break;
}
if (allIds.length === 0) return res.json({ labeled: 0, failed: 0, total: 0, ids: [], dryRun: true });
if (dryRun) return res.json({ labeled: 0, failed: 0, total: allIds.length, ids: allIds.slice(0, 50), dryRun: true });
// batchModify in chunks of 1000 (Gmail API limit)
let labeled = 0, failed = 0;
const CHUNK = 1000;
const modify = { addLabelIds: addLabelIds || [], removeLabelIds: removeLabelIds || [] };
for (let i = 0; i < allIds.length; i += CHUNK) {
try {
await g.users.messages.batchModify({ userId: 'me', requestBody: { ids: allIds.slice(i, i + CHUNK), ...modify } });
labeled += Math.min(CHUNK, allIds.length - i);
} catch (e) {
failed += Math.min(CHUNK, allIds.length - i);
console.warn(`[george] bulk-label chunk ${i} failed: ${e.message}`);
}
}
audit(account, 'bulk-label', { q, addLabelIds, removeLabelIds, total: allIds.length, labeled, failed });
res.json({ labeled, failed, total: allIds.length, ids: allIds.slice(0, 50), dryRun: false });
} 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: Update (revise) an existing Draft ─── TK-11231
// PUT /api/drafts/:id body: { to, subject, body, cc, bcc } (:id = the DRAFT id, e.g. "r-5804…", NOT the message id)
// Rationale: the create-only surface is what let agents compose DUPLICATE vendor asks
// (Command x2, Maya Romanoff x2, Newmor x2) — with no update verb an agent that finds
// its own stale draft can only compose a second one beside it. drafts.update REPLACES
// the draft in place, so revising is idempotent and leaves no second artifact.
// Reversible: superseded content is echoed back in `previous` so a bad edit can be re-applied.
app.put('/api/drafts/:id', 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}` });
// Read the draft first so the response can carry what was overwritten (undo material).
let previous = null;
try {
const before = await g.users.drafts.get({ userId: 'me', id: req.params.id, format: 'metadata' });
const hdrs = before.data.message?.payload?.headers || [];
const h = (n) => hdrs.find((x) => x.name.toLowerCase() === n)?.value || null;
previous = { messageId: before.data.message?.id, to: h('to'), subject: h('subject'), date: h('date') };
} catch (_) { /* non-fatal: a missing draft surfaces on the update below */ }
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.update({
userId: 'me',
id: req.params.id,
requestBody: { message: { raw: encoded } },
});
audit(account, 'draft-update', { draftId: req.params.id, to, subject, source, previous });
res.json({
success: true,
draftId: result.data.id,
messageId: result.data.message?.id,
account,
source,
previous,
status: 'awaiting_owner_send',
delivered: false,
});
} 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 }); }
});
// ─── Live-Check (TK-11952) — REAL Google round-trip, NOT object-existence ───
// /health reports ready:!!gmail — the in-memory OAuth object EXISTS. That stays truthy
// straight through a DNS-to-Google outage (getaddrinfo ENOTFOUND oauth2.googleapis.com,
// the actual 09-13 failure), so a canary greening on /health has a silent-PASS risk.
// This route instead performs the SAME cheap live call george already runs on OAuth
// exchange (gmail.users.getProfile, server.js:~698/882) and returns 200 {live:true} ONLY
// when the round-trip to Google succeeds. Any failure — unconfigured client, DNS/network
// unreachable, auth error, timeout — returns 503 {live:false}. FAIL-CLOSED: it can only
// ever be MORE conservative than /health, never a false green.
app.get('/api/live-check', async (req, res) => {
const started = Date.now();
// Default to the account the arm-health canaries watch (steve-office = `gmail`),
// but allow ?account= to probe any configured account via resolveAccount.
let gm, account;
try {
if (req.query.account) {
const r = resolveAccount(req); gm = r.gmail; account = r.key;
} else {
gm = gmail; account = 'steve-office';
}
} catch (e) {
return res.status(503).json({ live: false, account: account || null, error: 'account-resolve: ' + e.message, latency_ms: Date.now() - started });
}
if (!gm) {
// OAuth object absent — george is not authed for this account. Fail closed.
return res.status(503).json({ live: false, account: account || null, error: 'gmail client not configured (not authed)', latency_ms: Date.now() - started });
}
try {
// Bounded live round-trip. A DNS/network failure or auth error throws → 503.
const profile = await gm.users.getProfile({ userId: 'me' }, { timeout: 8000 });
return res.json({ live: true, account, email: profile.data.emailAddress, latency_ms: Date.now() - started });
} catch (e) {
return res.status(503).json({ live: false, account, error: e.message, latency_ms: Date.now() - started });
}
});
// ─── 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'
},
'agentabrams': {
ready: !!agentabramsGmail,
email: AGENTABRAMS_LOGIN_HINT,
services: { gmail: !!agentabramsGmail, tasks: !!agentabramsTasksApi, drive: !!agentabramsDrive, docs: !!agentabramsDocs, sheets: !!agentabramsSheets, slides: !!agentabramsSlides, calendar: !!agentabramsCalendar, forms: !!agentabramsForms },
authUrl: agentabramsGmail ? null : '/auth/agentabrams'
},
'calendar': {
ready: !!calendarApi,
email: 'calendar (dedicated calendar OAuth)',
services: { calendar: !!calendarApi },
authUrl: calendarApi ? null : '/auth/calendar'
}
}
});
});
// ─── 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`);
});