← back to George Gmail
server.js
2459 lines
/**
* George the Gmail Agent — Port 9889
* Secure email access for DW-Agents ecosystem
* Auth: admin / (set via GEORGE_BASIC_AUTH_PASS env var)
*/
const express = require('express');
const helmet = require('helmet');
const path = require('path');
const fs = require('fs');
const { google } = require('googleapis');
const nodemailer = require('nodemailer');
const PORT = process.env.PORT || 9850;
const AGENT_NAME = 'George';
const AGENT_CODENAME = 'Gmail';
const AGENT_COLOR = '#EA4335';
// ─── Gmail OAuth Credentials (loaded from DW-MCP .env) ───
// Portable across Mac2 (~/Projects/...) and Kamatera (/root/Projects/...).
// Try $HOME first, fall back to the legacy Kamatera path so we don't break
// the Kamatera deploy. Source of this fix: 2026-05-12 — on Mac2 the
// hardcoded /root/ path silently fails the readFileSync (caught by try/catch
// upstream) and George boots without INFO_REFRESH_TOKEN → "Info@ not
// configured" even though the token IS in ~/Projects/.../.env.
const ENV_PATH = (() => {
const tryPaths = [
process.env.DW_MCP_ENV,
require('path').join(process.env.HOME || '', 'Projects/Designer-Wallcoverings/DW-MCP/.env'),
'/root/Projects/Designer-Wallcoverings/DW-MCP/.env',
].filter(Boolean);
for (const p of tryPaths) {
try { require('fs').accessSync(p, require('fs').constants.R_OK); return p; } catch {}
}
return tryPaths[tryPaths.length - 1];
})();
const creds = {};
try {
const envContent = fs.readFileSync(ENV_PATH, 'utf8');
envContent.split('\n').forEach(line => {
const match = line.match(/^([^#=]+)=(.+)/);
if (match) creds[match[1].trim()] = match[2].trim();
});
console.log(`[${AGENT_NAME}] Loaded credentials from ${ENV_PATH}`);
} catch (e) {
console.error(`[${AGENT_NAME}] Failed to load env: ${e.message}`);
}
const GMAIL_CLIENT_ID = creds.GMAIL_CLIENT_ID || process.env.GMAIL_CLIENT_ID;
const GMAIL_CLIENT_SECRET = creds.GMAIL_CLIENT_SECRET || process.env.GMAIL_CLIENT_SECRET;
const GMAIL_REDIRECT_URI = 'http://localhost:9850/oauth2callback';
const GMAIL_REFRESH_TOKEN = creds.GMAIL_REFRESH_TOKEN || process.env.GMAIL_REFRESH_TOKEN;
// ─── Info@ Account Credentials ───
const INFO_REDIRECT_URI = 'http://localhost:9850/oauth2callback';
const INFO_REFRESH_TOKEN = creds.INFO_REFRESH_TOKEN || process.env.INFO_REFRESH_TOKEN;
// ─── Steve Personal (steveabramsdesigns@gmail.com) Credentials ───
// Reuses GMAIL_CLIENT_ID/SECRET (see PLAN-GSUITE-EXPANSION.md Q1=a).
const PERSONAL_REDIRECT_URI = 'http://localhost:9850/oauth2callback';
const PERSONAL_REFRESH_TOKEN = creds.PERSONAL_REFRESH_TOKEN || process.env.PERSONAL_REFRESH_TOKEN;
// ─── Agent Abrams (theagentabrams@gmail.com) Credentials ───
// Reuses GMAIL_CLIENT_ID/SECRET. Added 2026-05-18 per Steve.
const AGENTABRAMS_REDIRECT_URI = 'http://localhost:9850/oauth2callback';
const AGENTABRAMS_REFRESH_TOKEN = creds.AGENTABRAMS_REFRESH_TOKEN || process.env.AGENTABRAMS_REFRESH_TOKEN;
const AGENTABRAMS_LOGIN_HINT = creds.AGENTABRAMS_LOGIN_HINT || process.env.AGENTABRAMS_LOGIN_HINT || 'theagentabrams@gmail.com';
// ─── OAuth2 Scopes ───
// FULL_WORKSPACE_SCOPES = unified scope list for all 3 accounts.
// Steve approved including Forms (Q3=y) so we don't have to re-OAuth later.
const FULL_WORKSPACE_SCOPES = [
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.settings.sharing', // sendAs alias create/update
'https://www.googleapis.com/auth/gmail.settings.basic', // filters / labels
'https://www.googleapis.com/auth/tasks',
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/documents',
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/presentations',
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/forms.body',
'https://www.googleapis.com/auth/forms.responses.readonly',
];
// Kept as aliases so older references don't break mid-rollout.
const SCOPES = FULL_WORKSPACE_SCOPES;
const INFO_SCOPES = FULL_WORKSPACE_SCOPES;
const PERSONAL_SCOPES = FULL_WORKSPACE_SCOPES;
// ─── Service-account / domain-wide-delegation (DORMANT until configured) ──────
// The Testing-mode OAuth app expires refresh tokens ~weekly (see PLAN-OAUTH-DURABILITY.md).
// For Workspace accounts we can switch to a service account with domain-wide delegation:
// the SA impersonates the user and mints tokens per request — no refresh token, no weekly
// expiry, no consent screen. FULLY DORMANT by default: with no GOOGLE_SA_KEYFILE this whole
// path is skipped and every account keeps using its existing refresh-token client byte-for-byte.
// To arm (AFTER Steve authorizes the SA client ID for the scopes in Workspace Admin →
// Security → API controls → Domain-wide delegation), set in the DW-MCP .env:
// GOOGLE_SA_KEYFILE=/abs/path/sa-key.json (route via the secrets skill)
// GEORGE_SA_ACCOUNTS=steve-office,info (Workspace accounts ONLY)
// Consumer @gmail accounts (steve-personal, agentabrams) CANNOT use DWD and are ignored
// even if listed — they stay on the refresh-token path + the 6-day token-age WARN.
const SA_KEYFILE = creds.GOOGLE_SA_KEYFILE || process.env.GOOGLE_SA_KEYFILE || creds.GEORGE_SA_KEYFILE || process.env.GEORGE_SA_KEYFILE || '';
const SA_ACCOUNTS = String(creds.GEORGE_SA_ACCOUNTS || process.env.GEORGE_SA_ACCOUNTS || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
// Only these Workspace accounts have an impersonable domain address; consumer gmail keys are
// intentionally absent so they can never be SA-backed.
const SA_IMPERSONATE = { 'steve-office': 'steve@designerwallcoverings.com', 'info': 'info@designerwallcoverings.com' };
const SA_KEY_OK = !!SA_KEYFILE && (() => { try { fs.accessSync(SA_KEYFILE, fs.constants.R_OK); return true; } catch { return false; } })();
if (SA_ACCOUNTS.length && !SA_KEY_OK) console.warn(`[${AGENT_NAME}] SA/DWD requested for [${SA_ACCOUNTS}] but key unreadable at "${SA_KEYFILE}" — staying on refresh-token path`);
// Domain-wide-delegation JWT auth for `key`, or null if not SA-eligible/armed.
function saAuthFor(key) {
if (!SA_KEY_OK || !SA_ACCOUNTS.includes(key)) return null;
const subject = SA_IMPERSONATE[key];
if (!subject) { console.warn(`[${AGENT_NAME}] SA/DWD skip "${key}" — no impersonable Workspace address (consumer gmail can't use DWD)`); return null; }
try {
return new google.auth.JWT({ keyFile: SA_KEYFILE, scopes: FULL_WORKSPACE_SCOPES, subject });
} catch (e) { console.error(`[${AGENT_NAME}] SA/DWD auth build failed for "${key}": ${e.message}`); return null; }
}
// Steve@ account (primary)
let oauth2Client;
let gmail;
let tasksApi;
try {
oauth2Client = new google.auth.OAuth2(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REDIRECT_URI);
oauth2Client.setCredentials({ refresh_token: GMAIL_REFRESH_TOKEN });
gmail = google.gmail({ version: 'v1', auth: oauth2Client });
tasksApi = google.tasks({ version: 'v1', auth: oauth2Client });
console.log(`[${AGENT_NAME}] Gmail + Tasks API client initialized (steve@)`);
} catch (e) {
console.error(`[${AGENT_NAME}] API client error: ${e.message}`);
}
// Info@ account (secondary — now full Workspace)
let infoOauth2Client;
let infoGmail;
let infoTasksApi;
let infoDrive;
let infoDocs;
let infoSheets;
let infoSlides;
let infoCalendar;
let infoForms;
try {
infoOauth2Client = new google.auth.OAuth2(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, INFO_REDIRECT_URI);
if (INFO_REFRESH_TOKEN) {
infoOauth2Client.setCredentials({ refresh_token: INFO_REFRESH_TOKEN });
infoGmail = google.gmail({ version: 'v1', auth: infoOauth2Client });
infoTasksApi = google.tasks({ version: 'v1', auth: infoOauth2Client });
infoDrive = google.drive({ version: 'v3', auth: infoOauth2Client });
infoDocs = google.docs({ version: 'v1', auth: infoOauth2Client });
infoSheets = google.sheets({ version: 'v4', auth: infoOauth2Client });
infoSlides = google.slides({ version: 'v1', auth: infoOauth2Client });
infoCalendar = google.calendar({ version: 'v3', auth: infoOauth2Client });
infoForms = google.forms({ version: 'v1', auth: infoOauth2Client });
console.log(`[${AGENT_NAME}] Info@ account initialized (full Workspace)`);
} else {
console.log(`[${AGENT_NAME}] Info@ not configured — visit /auth/info to authorize`);
}
} catch (e) {
console.error(`[${AGENT_NAME}] Info@ client error: ${e.message}`);
}
// ─── Steve@dw account — upgrade to full Workspace ───
let docs, sheets, slides, drive, calendar, forms;
try {
if (oauth2Client && GMAIL_REFRESH_TOKEN) {
docs = google.docs({ version: 'v1', auth: oauth2Client });
sheets = google.sheets({ version: 'v4', auth: oauth2Client });
slides = google.slides({ version: 'v1', auth: oauth2Client });
drive = google.drive({ version: 'v3', auth: oauth2Client });
calendar = google.calendar({ version: 'v3', auth: oauth2Client });
forms = google.forms({ version: 'v1', auth: oauth2Client });
console.log(`[${AGENT_NAME}] Steve Office account upgraded to full Workspace`);
}
} catch (e) {
console.error(`[${AGENT_NAME}] Steve Office Workspace upgrade error: ${e.message}`);
}
// ─── Steve Personal (steveabramsdesigns@gmail.com) account ───
let personalOauth2Client;
let personalGmail, personalTasksApi, personalDrive, personalDocs, personalSheets, personalSlides, personalCalendar, personalForms;
try {
personalOauth2Client = new google.auth.OAuth2(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, PERSONAL_REDIRECT_URI);
if (PERSONAL_REFRESH_TOKEN) {
personalOauth2Client.setCredentials({ refresh_token: PERSONAL_REFRESH_TOKEN });
personalGmail = google.gmail({ version: 'v1', auth: personalOauth2Client });
personalTasksApi = google.tasks({ version: 'v1', auth: personalOauth2Client });
personalDrive = google.drive({ version: 'v3', auth: personalOauth2Client });
personalDocs = google.docs({ version: 'v1', auth: personalOauth2Client });
personalSheets = google.sheets({ version: 'v4', auth: personalOauth2Client });
personalSlides = google.slides({ version: 'v1', auth: personalOauth2Client });
personalCalendar = google.calendar({ version: 'v3', auth: personalOauth2Client });
personalForms = google.forms({ version: 'v1', auth: personalOauth2Client });
console.log(`[${AGENT_NAME}] Steve Personal account initialized (full Workspace)`);
} else {
console.log(`[${AGENT_NAME}] Steve Personal not configured — visit /auth/steve-personal to authorize`);
}
} catch (e) {
console.error(`[${AGENT_NAME}] Steve Personal client error: ${e.message}`);
}
// ─── Agent Abrams (theagentabrams@gmail.com) account ───
let agentabramsOauth2Client;
let agentabramsGmail, agentabramsTasksApi, agentabramsDrive, agentabramsDocs, agentabramsSheets, agentabramsSlides, agentabramsCalendar, agentabramsForms;
try {
agentabramsOauth2Client = new google.auth.OAuth2(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, AGENTABRAMS_REDIRECT_URI);
if (AGENTABRAMS_REFRESH_TOKEN) {
agentabramsOauth2Client.setCredentials({ refresh_token: AGENTABRAMS_REFRESH_TOKEN });
agentabramsGmail = google.gmail ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsTasksApi = google.tasks ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsDrive = google.drive ({ version: 'v3', auth: agentabramsOauth2Client });
agentabramsDocs = google.docs ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsSheets = google.sheets ({ version: 'v4', auth: agentabramsOauth2Client });
agentabramsSlides = google.slides ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsCalendar = google.calendar({ version: 'v3', auth: agentabramsOauth2Client });
agentabramsForms = google.forms ({ version: 'v1', auth: agentabramsOauth2Client });
console.log(`[${AGENT_NAME}] Agent Abrams account initialized (full Workspace)`);
} else {
console.log(`[${AGENT_NAME}] Agent Abrams not configured — visit /auth/agentabrams to authorize`);
}
} catch (e) {
console.error(`[${AGENT_NAME}] Agent Abrams client error: ${e.message}`);
}
// ─── SA/DWD override (DORMANT unless GOOGLE_SA_KEYFILE + GEORGE_SA_ACCOUNTS set) ─
// For each SA-armed Workspace account, rebuild its per-service API clients on the
// domain-wide-delegation JWT auth, replacing the refresh-token clients. Additive:
// when SA is off, saAuthFor() returns null and this block is a no-op, leaving the
// original refresh-token clients exactly as-is. resolveAccount() reads these
// module-level vars per request, so it transparently picks up the SA clients.
try {
const svc = (auth) => ({
gmail: google.gmail ({ version: 'v1', auth }),
tasks: google.tasks ({ version: 'v1', auth }),
drive: google.drive ({ version: 'v3', auth }),
docs: google.docs ({ version: 'v1', auth }),
sheets: google.sheets ({ version: 'v4', auth }),
slides: google.slides ({ version: 'v1', auth }),
calendar: google.calendar({ version: 'v3', auth }),
forms: google.forms ({ version: 'v1', auth }),
});
const saSteve = saAuthFor('steve-office');
if (saSteve) {
const s = svc(saSteve);
oauth2Client = saSteve; gmail = s.gmail; tasksApi = s.tasks;
drive = s.drive; docs = s.docs; sheets = s.sheets; slides = s.slides; calendar = s.calendar; forms = s.forms;
console.log(`[${AGENT_NAME}] steve-office → SA/DWD (impersonating ${SA_IMPERSONATE['steve-office']}) — refresh-token path bypassed`);
}
const saInfo = saAuthFor('info');
if (saInfo) {
const s = svc(saInfo);
infoOauth2Client = saInfo; infoGmail = s.gmail; infoTasksApi = s.tasks;
infoDrive = s.drive; infoDocs = s.docs; infoSheets = s.sheets; infoSlides = s.slides; infoCalendar = s.calendar; infoForms = s.forms;
console.log(`[${AGENT_NAME}] info → SA/DWD (impersonating ${SA_IMPERSONATE['info']}) — refresh-token path bypassed`);
}
} catch (e) {
console.error(`[${AGENT_NAME}] SA/DWD override error (staying on refresh-token path): ${e.message}`);
}
// ─── Account resolver for per-service API endpoints ───
// account query/body param: "steve-office" (default), "info", "steve-personal", "agentabrams"
function resolveAccount(req) {
const key = (req.query.account || req.body?.account || 'steve-office').toLowerCase();
const map = {
'steve-office': { label: 'steve@designerwallcoverings.com', oauth: oauth2Client, gmail, tasks: tasksApi, drive, docs, sheets, slides, calendar, forms, refreshToken: GMAIL_REFRESH_TOKEN },
'info': { label: 'info@designerwallcoverings.com', oauth: infoOauth2Client, gmail: infoGmail, tasks: infoTasksApi, drive: infoDrive, docs: infoDocs, sheets: infoSheets, slides: infoSlides, calendar: infoCalendar, forms: infoForms, refreshToken: INFO_REFRESH_TOKEN },
'steve-personal': { label: 'steveabramsdesigns@gmail.com', oauth: personalOauth2Client, gmail: personalGmail, tasks: personalTasksApi, drive: personalDrive, docs: personalDocs, sheets: personalSheets, slides: personalSlides, calendar: personalCalendar, forms: personalForms, refreshToken: PERSONAL_REFRESH_TOKEN },
'agentabrams': { label: AGENTABRAMS_LOGIN_HINT, oauth: agentabramsOauth2Client, gmail: agentabramsGmail, tasks: agentabramsTasksApi, drive: agentabramsDrive, docs: agentabramsDocs, sheets: agentabramsSheets, slides: agentabramsSlides, calendar: agentabramsCalendar, forms: agentabramsForms, refreshToken: AGENTABRAMS_REFRESH_TOKEN },
};
return { key, ...(map[key] || {}) };
}
// Audit log for every Workspace write operation
const AUDIT_LOG = '/root/DW-Agents/logs/george-workspace-audit.log';
function audit(account, action, details) {
try {
const line = `${new Date().toISOString()}\t${account}\t${action}\t${JSON.stringify(details)}\n`;
fs.appendFileSync(AUDIT_LOG, line);
} catch {}
}
// ─── Express App ───
const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json({ limit: '50mb' }));
// ─── Basic Auth credential resolution (2026-05-24 migration PR-1) ───
// Reads GEORGE_BASIC_AUTH=user:pass from env if present; falls back to the
// historical hardcoded value so the ~20 caller scripts in /root/DW-Agents/
// keep working unchanged. Future PRs in the migration plan documented at
// /root/DW-Agents/KNOWN-ISSUE-george-auth.md will switch callers off the
// hardcoded value, then rotate, then drop the fallback.
const _GEORGE_AUTH_ENV =
(typeof creds !== "undefined" && creds.GEORGE_BASIC_AUTH) ||
process.env.GEORGE_BASIC_AUTH ||
"";
const _GEORGE_AUTH_SOURCE = _GEORGE_AUTH_ENV ? "env" : "hardcoded-fallback";
const [_GEORGE_USER, _GEORGE_PASS] = _GEORGE_AUTH_ENV.includes(":")
? _GEORGE_AUTH_ENV.split(":")
: ["admin", (process.env.GEORGE_BASIC_AUTH_PASS || "")];
console.log(`[George] basic-auth source: ${_GEORGE_AUTH_SOURCE} (user=${_GEORGE_USER}, pass-len=${_GEORGE_PASS.length})`);
const crypto = require("crypto");
function _ctEq(a, b) {
const ab = Buffer.from(String(a)); const bb = Buffer.from(String(b));
if (ab.length !== bb.length) { crypto.timingSafeEqual(ab, ab); return false; }
return crypto.timingSafeEqual(ab, bb);
}
// ─── Basic Auth (skip health + OAuth callbacks) ───
app.use((req, res, next) => {
const publicPaths = ['/health', '/auth', '/auth/info', '/auth/steve-office', '/auth/steve-personal', '/auth/agentabrams', '/oauth2callback'];
if (publicPaths.includes(req.path)) return next();
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Basic ')) {
res.setHeader('WWW-Authenticate', 'Basic realm="George Gmail Agent"');
return res.status(401).json({ error: 'Authentication required' });
}
// Fail closed: an unset/empty credential must never mean "no auth" — this
// server binds all interfaces and fronts live Gmail accounts (2026-07-13,
// found running with pass-len=0 after a half-applied rotation).
if (!_GEORGE_PASS) return res.status(503).json({ error: 'Auth not configured on server' });
const decoded = Buffer.from(auth.slice(6), 'base64').toString();
const [user, pass] = decoded.split(':');
if (_ctEq(user, _GEORGE_USER) && _ctEq(pass, _GEORGE_PASS)) return next();
res.setHeader('WWW-Authenticate', 'Basic realm="George Gmail Agent"');
res.status(401).json({ error: 'Invalid credentials' });
});
// ─── Static-snapshot 404 guard ───
// Defense-in-depth: never serve *.bak / *.pre-* / *.orig / *.rej / *~ from
// the static root even if a stray snapshot lands in public/. Runs before
// express.static so the snapshot file is invisible regardless of disk state.
app.use((req, res, next) => {
if (/\.(bak|orig|rej|swp)(\.|$)|\.bak-|\.pre-|~$/.test(req.path)) {
return res.status(404).send('Not found');
}
next();
});
app.use(express.static(path.join(__dirname, 'public')));
// ─── OAuth Re-Authorization Flow ───
const OAUTH_REDIRECT = 'http://localhost:9850/oauth2callback';
app.get('/auth', (req, res) => {
// Honor ?account= — dispatch to the matching dedicated re-auth route so
// each account gets its own oauth client, scopes, login_hint, and state.
// Keys match resolveAccount(): steve-office (default), info, steve-personal, agentabrams.
const acct = (req.query.account || 'steve-office').toLowerCase();
const routes = {
'info': '/auth/info',
'steve-personal': '/auth/steve-personal',
'agentabrams': '/auth/agentabrams',
};
if (routes[acct]) return res.redirect(routes[acct]);
// steve-office (default, or unknown account key) — full Workspace re-auth for steve@
if (!oauth2Client) return res.status(500).send('OAuth client not configured');
const url = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: FULL_WORKSPACE_SCOPES,
prompt: 'consent',
redirect_uri: OAUTH_REDIRECT,
login_hint: 'steve@designerwallcoverings.com',
});
res.redirect(url);
});
// Alias: /auth/steve-office — same as /auth, clearer name
app.get('/auth/steve-office', (req, res) => res.redirect('/auth'));
app.get('/oauth2callback', async (req, res) => {
try {
const { code, state } = req.query;
if (!code) return res.status(400).send('No authorization code received');
// Route to agentabrams handler if state=agentabrams
if (state === 'agentabrams') {
const { tokens } = await agentabramsOauth2Client.getToken({ code, redirect_uri: AGENTABRAMS_REDIRECT_URI });
agentabramsOauth2Client.setCredentials(tokens);
agentabramsGmail = google.gmail ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsTasksApi = google.tasks ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsDrive = google.drive ({ version: 'v3', auth: agentabramsOauth2Client });
agentabramsDocs = google.docs ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsSheets = google.sheets ({ version: 'v4', auth: agentabramsOauth2Client });
agentabramsSlides = google.slides ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsCalendar = google.calendar({ version: 'v3', auth: agentabramsOauth2Client });
agentabramsForms = google.forms ({ version: 'v1', auth: agentabramsOauth2Client });
let msg = '<html><body style="font-family:sans-serif;background:#0f1117;color:#e4e4e7;padding:40px">';
msg += '<h2 style="color:#4ade80">Agent Abrams Authorized! (Full Google Workspace)</h2>';
if (tokens.refresh_token) {
msg += `<p><strong>New Agent Abrams Refresh Token:</strong></p><code style="background:#1a1d27;padding:10px;display:block;word-break:break-all;border-radius:8px">${tokens.refresh_token}</code>`;
console.log(`[${AGENT_NAME}] Agent Abrams refresh token obtained: ${tokens.refresh_token.substring(0, 20)}...`);
try {
let envContent = fs.readFileSync(ENV_PATH, 'utf8');
if (envContent.includes('AGENTABRAMS_REFRESH_TOKEN=')) {
envContent = envContent.replace(/AGENTABRAMS_REFRESH_TOKEN=.*/, `AGENTABRAMS_REFRESH_TOKEN=${tokens.refresh_token}`);
} else {
envContent += `\n# Agent Abrams (theagentabrams@gmail.com) OAuth Token\nAGENTABRAMS_REFRESH_TOKEN=${tokens.refresh_token}\n`;
}
fs.writeFileSync(ENV_PATH, envContent);
msg += '<p style="color:#4ade80">Token auto-saved to central .env!</p>';
audit('agentabrams', 'oauth-authorized', { scopes: FULL_WORKSPACE_SCOPES.length });
} catch (e) {
msg += `<p style="color:#f87171">Could not auto-save: ${e.message}</p>`;
}
}
try {
const profile = await agentabramsGmail.users.getProfile({ userId: 'me' });
msg += `<p style="color:#60a5fa">Connected as: <strong>${profile.data.emailAddress}</strong></p>`;
} catch {}
msg += '<p style="color:#a3a3a3">You can close this tab. Restart george-gmail to load the new token persistently.</p>';
msg += '<p><a href="/" style="color:#60a5fa">Go to Dashboard</a></p></body></html>';
return res.send(msg);
}
// Route to steve-personal handler if state=personal
if (state === 'personal') {
const { tokens } = await personalOauth2Client.getToken({ code, redirect_uri: PERSONAL_REDIRECT_URI });
personalOauth2Client.setCredentials(tokens);
personalGmail = google.gmail({ version: 'v1', auth: personalOauth2Client });
personalTasksApi = google.tasks({ version: 'v1', auth: personalOauth2Client });
personalDrive = google.drive({ version: 'v3', auth: personalOauth2Client });
personalDocs = google.docs({ version: 'v1', auth: personalOauth2Client });
personalSheets = google.sheets({ version: 'v4', auth: personalOauth2Client });
personalSlides = google.slides({ version: 'v1', auth: personalOauth2Client });
personalCalendar = google.calendar({ version: 'v3', auth: personalOauth2Client });
personalForms = google.forms({ version: 'v1', auth: personalOauth2Client });
let msg = '<html><body style="font-family:sans-serif;background:#0f1117;color:#e4e4e7;padding:40px">';
msg += '<h2 style="color:#4ade80">Steve Personal Authorized! (Full Google Workspace)</h2>';
if (tokens.refresh_token) {
msg += `<p><strong>New Personal Refresh Token:</strong></p><code style="background:#1a1d27;padding:10px;display:block;word-break:break-all;border-radius:8px">${tokens.refresh_token}</code>`;
console.log(`[${AGENT_NAME}] Personal refresh token obtained: ${tokens.refresh_token.substring(0, 20)}...`);
try {
let envContent = fs.readFileSync(ENV_PATH, 'utf8');
if (envContent.includes('PERSONAL_REFRESH_TOKEN=')) {
envContent = envContent.replace(/PERSONAL_REFRESH_TOKEN=.*/, `PERSONAL_REFRESH_TOKEN=${tokens.refresh_token}`);
} else {
envContent += `\n# Steve Personal (steveabramsdesigns@gmail.com) OAuth Token\nPERSONAL_REFRESH_TOKEN=${tokens.refresh_token}\n`;
}
fs.writeFileSync(ENV_PATH, envContent);
msg += '<p style="color:#4ade80">Token auto-saved to central .env!</p>';
audit('steve-personal', 'oauth-authorized', { scopes: PERSONAL_SCOPES.length });
} catch (e) {
msg += `<p style="color:#f87171">Could not auto-save: ${e.message}</p>`;
}
}
try {
const profile = await personalGmail.users.getProfile({ userId: 'me' });
msg += `<p style="color:#60a5fa">Connected as: <strong>${profile.data.emailAddress}</strong></p>`;
} catch {}
msg += '<p><a href="/" style="color:#60a5fa">Go to Dashboard</a></p></body></html>';
return res.send(msg);
}
// Route to info@ handler if state=info
if (state === 'info') {
const { tokens } = await infoOauth2Client.getToken({ code, redirect_uri: INFO_REDIRECT_URI });
infoOauth2Client.setCredentials(tokens);
infoGmail = google.gmail({ version: 'v1', auth: infoOauth2Client });
infoTasksApi = google.tasks({ version: 'v1', auth: infoOauth2Client });
infoDrive = google.drive({ version: 'v3', auth: infoOauth2Client });
let msg = '<html><body style="font-family:sans-serif;background:#0f1117;color:#e4e4e7;padding:40px">';
msg += '<h2 style="color:#4ade80">Info@ Authorization Successful! (Gmail + Tasks + Drive)</h2>';
if (tokens.refresh_token) {
msg += `<p><strong>New Info@ Refresh Token:</strong></p><code style="background:#1a1d27;padding:10px;display:block;word-break:break-all;border-radius:8px">${tokens.refresh_token}</code>`;
msg += '<p style="color:#f59e0b">Save this token to your .env file as INFO_REFRESH_TOKEN</p>';
console.log(`[${AGENT_NAME}] Info@ refresh token obtained: ${tokens.refresh_token.substring(0, 20)}...`);
try {
let envContent = fs.readFileSync(ENV_PATH, 'utf8');
if (envContent.includes('INFO_REFRESH_TOKEN=')) {
envContent = envContent.replace(/INFO_REFRESH_TOKEN=.*/, `INFO_REFRESH_TOKEN=${tokens.refresh_token}`);
} else {
envContent += `\n# Info@ Account OAuth Token\nINFO_REFRESH_TOKEN=${tokens.refresh_token}\n`;
}
fs.writeFileSync(ENV_PATH, envContent);
msg += '<p style="color:#4ade80">Token auto-saved to .env!</p>';
} catch (e) {
msg += `<p style="color:#f87171">Could not auto-save: ${e.message}</p>`;
}
}
try {
const profile = await infoGmail.users.getProfile({ userId: 'me' });
msg += `<p style="color:#60a5fa">Connected as: <strong>${profile.data.emailAddress}</strong></p>`;
} catch {}
msg += '<p><a href="/" style="color:#60a5fa">Go to Dashboard</a></p></body></html>';
return res.send(msg);
}
// Default: steve@ account (now full Workspace)
const { tokens } = await oauth2Client.getToken({ code, redirect_uri: OAUTH_REDIRECT });
oauth2Client.setCredentials(tokens);
gmail = google.gmail({ version: 'v1', auth: oauth2Client });
tasksApi = google.tasks({ version: 'v1', auth: oauth2Client });
drive = google.drive({ version: 'v3', auth: oauth2Client });
docs = google.docs({ version: 'v1', auth: oauth2Client });
sheets = google.sheets({ version: 'v4', auth: oauth2Client });
slides = google.slides({ version: 'v1', auth: oauth2Client });
calendar = google.calendar({ version: 'v3', auth: oauth2Client });
forms = google.forms({ version: 'v1', auth: oauth2Client });
let msg = '<html><body style="font-family:sans-serif;background:#0f1117;color:#e4e4e7;padding:40px">';
msg += '<h2 style="color:#4ade80">Authorization Successful! (Full Google Workspace)</h2>';
if (tokens.refresh_token) {
msg += `<p><strong>New Refresh Token:</strong></p><code style="background:#1a1d27;padding:10px;display:block;word-break:break-all;border-radius:8px">${tokens.refresh_token}</code>`;
console.log(`[${AGENT_NAME}] New refresh token obtained: ${tokens.refresh_token.substring(0, 20)}...`);
try {
let envContent = fs.readFileSync(ENV_PATH, 'utf8');
if (envContent.includes('GMAIL_REFRESH_TOKEN=')) {
envContent = envContent.replace(/GMAIL_REFRESH_TOKEN=.*/, `GMAIL_REFRESH_TOKEN=${tokens.refresh_token}`);
} else {
envContent += `\nGMAIL_REFRESH_TOKEN=${tokens.refresh_token}\n`;
}
fs.writeFileSync(ENV_PATH, envContent);
msg += '<p style="color:#4ade80">Token auto-saved to central .env!</p>';
audit('steve-office', 'oauth-authorized', { scopes: FULL_WORKSPACE_SCOPES.length });
} catch (e) {
msg += `<p style="color:#f87171">Could not auto-save: ${e.message}</p>`;
}
} else {
msg += '<p>Access token refreshed (existing refresh token reused).</p>';
}
msg += '<p style="color:#4ade80">Full Google Workspace is now ready for steve@.</p>';
msg += '<p><a href="/" style="color:#60a5fa">Go to Dashboard</a></p></body></html>';
res.send(msg);
} catch (e) {
console.error(`[${AGENT_NAME}] OAuth callback error:`, e.message);
res.status(500).send(`<html><body style="font-family:sans-serif;background:#0f1117;color:#f87171;padding:40px"><h2>Authorization Failed</h2><p>${e.message}</p><p><a href="/auth" style="color:#60a5fa">Try Again</a></p></body></html>`);
}
});
// ─── Info@ OAuth Authorization Flow ───
app.get('/auth/info', (req, res) => {
if (!infoOauth2Client) return res.status(500).send('OAuth client not configured');
const url = infoOauth2Client.generateAuthUrl({
access_type: 'offline',
scope: INFO_SCOPES,
prompt: 'consent',
redirect_uri: INFO_REDIRECT_URI,
login_hint: 'info@designerwallcoverings.com',
state: 'info',
});
res.redirect(url);
});
app.get('/api/auth-url/info', (req, res) => {
if (!infoOauth2Client) return res.status(500).json({ error: 'OAuth client not configured' });
const url = infoOauth2Client.generateAuthUrl({
access_type: 'offline',
scope: INFO_SCOPES,
prompt: 'consent',
redirect_uri: INFO_REDIRECT_URI,
login_hint: 'info@designerwallcoverings.com',
state: 'info',
});
res.json({ authUrl: url, account: 'info@designerwallcoverings.com', scopes: INFO_SCOPES, instructions: 'Visit this URL in a browser where you are logged into info@designerwallcoverings.com. Approve access, then copy the code from the redirect URL and pass to /api/exchange-code/info?code=YOUR_CODE' });
});
// oauth2callback-info is handled by the unified /oauth2callback via state=info
// ─── Agent Abrams OAuth Authorization Flow ───
app.get('/auth/agentabrams', (req, res) => {
if (!agentabramsOauth2Client) return res.status(500).send('Agent Abrams OAuth client not configured');
const url = agentabramsOauth2Client.generateAuthUrl({
access_type: 'offline',
scope: FULL_WORKSPACE_SCOPES,
prompt: 'consent',
redirect_uri: AGENTABRAMS_REDIRECT_URI,
login_hint: AGENTABRAMS_LOGIN_HINT,
state: 'agentabrams',
});
res.redirect(url);
});
app.get('/api/auth-url/agentabrams', (req, res) => {
if (!agentabramsOauth2Client) return res.status(500).json({ error: 'Agent Abrams OAuth client not configured' });
const url = agentabramsOauth2Client.generateAuthUrl({
access_type: 'offline',
scope: FULL_WORKSPACE_SCOPES,
prompt: 'consent',
redirect_uri: AGENTABRAMS_REDIRECT_URI,
login_hint: AGENTABRAMS_LOGIN_HINT,
state: 'agentabrams',
});
res.json({ authUrl: url, account: AGENTABRAMS_LOGIN_HINT, scopes: FULL_WORKSPACE_SCOPES });
});
app.get('/api/exchange-code/agentabrams', async (req, res) => {
try {
const { code } = req.query;
if (!code) return res.status(400).json({ error: 'code parameter required' });
const { tokens } = await agentabramsOauth2Client.getToken({ code, redirect_uri: AGENTABRAMS_REDIRECT_URI });
agentabramsOauth2Client.setCredentials(tokens);
agentabramsGmail = google.gmail ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsTasksApi = google.tasks ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsDrive = google.drive ({ version: 'v3', auth: agentabramsOauth2Client });
agentabramsDocs = google.docs ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsSheets = google.sheets ({ version: 'v4', auth: agentabramsOauth2Client });
agentabramsSlides = google.slides ({ version: 'v1', auth: agentabramsOauth2Client });
agentabramsCalendar = google.calendar({ version: 'v3', auth: agentabramsOauth2Client });
agentabramsForms = google.forms ({ version: 'v1', auth: agentabramsOauth2Client });
const result = { success: true, account: 'agentabrams', hasRefreshToken: !!tokens.refresh_token };
if (tokens.refresh_token) {
result.refreshToken = tokens.refresh_token;
try {
let envContent = fs.readFileSync(ENV_PATH, 'utf8');
if (envContent.includes('AGENTABRAMS_REFRESH_TOKEN=')) {
envContent = envContent.replace(/AGENTABRAMS_REFRESH_TOKEN=.*/, `AGENTABRAMS_REFRESH_TOKEN=${tokens.refresh_token}`);
} else {
envContent += `\n# Agent Abrams (theagentabrams@gmail.com) OAuth Token\nAGENTABRAMS_REFRESH_TOKEN=${tokens.refresh_token}\n`;
}
fs.writeFileSync(ENV_PATH, envContent);
result.savedToEnv = true;
audit('agentabrams', 'oauth-authorized', { scopes: FULL_WORKSPACE_SCOPES.length });
} catch (e) {
result.savedToEnv = false;
result.envError = e.message;
}
}
try {
const profile = await agentabramsGmail.users.getProfile({ userId: 'me' });
result.email = profile.data.emailAddress;
result.ready = true;
} catch (e) {
result.ready = false;
result.testError = e.message;
}
res.json(result);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── Steve Personal OAuth Authorization Flow ───
app.get('/auth/steve-personal', (req, res) => {
if (!personalOauth2Client) return res.status(500).send('Personal OAuth client not configured');
const url = personalOauth2Client.generateAuthUrl({
access_type: 'offline',
scope: PERSONAL_SCOPES,
prompt: 'consent',
redirect_uri: PERSONAL_REDIRECT_URI,
login_hint: 'steveabramsdesigns@gmail.com',
state: 'personal',
});
res.redirect(url);
});
app.get('/api/auth-url/steve-personal', (req, res) => {
if (!personalOauth2Client) return res.status(500).json({ error: 'Personal OAuth client not configured' });
const url = personalOauth2Client.generateAuthUrl({
access_type: 'offline',
scope: PERSONAL_SCOPES,
prompt: 'consent',
redirect_uri: PERSONAL_REDIRECT_URI,
login_hint: 'steveabramsdesigns@gmail.com',
state: 'personal',
});
res.json({ authUrl: url, account: 'steveabramsdesigns@gmail.com', scopes: PERSONAL_SCOPES });
});
app.get('/api/exchange-code/steve-personal', async (req, res) => {
try {
const { code } = req.query;
if (!code) return res.status(400).json({ error: 'code parameter required' });
const { tokens } = await personalOauth2Client.getToken({ code, redirect_uri: PERSONAL_REDIRECT_URI });
personalOauth2Client.setCredentials(tokens);
personalGmail = google.gmail({ version: 'v1', auth: personalOauth2Client });
personalTasksApi = google.tasks({ version: 'v1', auth: personalOauth2Client });
personalDrive = google.drive({ version: 'v3', auth: personalOauth2Client });
personalDocs = google.docs({ version: 'v1', auth: personalOauth2Client });
personalSheets = google.sheets({ version: 'v4', auth: personalOauth2Client });
personalSlides = google.slides({ version: 'v1', auth: personalOauth2Client });
personalCalendar = google.calendar({ version: 'v3', auth: personalOauth2Client });
personalForms = google.forms({ version: 'v1', auth: personalOauth2Client });
const result = { success: true, account: 'steve-personal', hasRefreshToken: !!tokens.refresh_token };
if (tokens.refresh_token) {
result.refreshToken = tokens.refresh_token;
try {
let envContent = fs.readFileSync(ENV_PATH, 'utf8');
if (envContent.includes('PERSONAL_REFRESH_TOKEN=')) {
envContent = envContent.replace(/PERSONAL_REFRESH_TOKEN=.*/, `PERSONAL_REFRESH_TOKEN=${tokens.refresh_token}`);
} else {
envContent += `\n# Steve Personal (steveabramsdesigns@gmail.com) OAuth Token\nPERSONAL_REFRESH_TOKEN=${tokens.refresh_token}\n`;
}
fs.writeFileSync(ENV_PATH, envContent);
result.savedToEnv = true;
audit('steve-personal', 'oauth-authorized', { scopes: PERSONAL_SCOPES.length });
} catch (e) {
result.savedToEnv = false;
result.envError = e.message;
}
}
try {
const profile = await personalGmail.users.getProfile({ userId: 'me' });
result.email = profile.data.emailAddress;
result.ready = true;
} catch (e) {
result.ready = false;
result.testError = e.message;
}
res.json(result);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/exchange-code/info', async (req, res) => {
try {
const { code } = req.query;
if (!code) return res.status(400).json({ error: 'code parameter required' });
const { tokens } = await infoOauth2Client.getToken({ code, redirect_uri: INFO_REDIRECT_URI });
infoOauth2Client.setCredentials(tokens);
infoGmail = google.gmail({ version: 'v1', auth: infoOauth2Client });
infoTasksApi = google.tasks({ version: 'v1', auth: infoOauth2Client });
infoDrive = google.drive({ version: 'v3', auth: infoOauth2Client });
const result = { success: true, account: 'info@', hasRefreshToken: !!tokens.refresh_token };
if (tokens.refresh_token) {
result.refreshToken = tokens.refresh_token;
try {
let envContent = fs.readFileSync(ENV_PATH, 'utf8');
if (envContent.includes('INFO_REFRESH_TOKEN=')) {
envContent = envContent.replace(/INFO_REFRESH_TOKEN=.*/, `INFO_REFRESH_TOKEN=${tokens.refresh_token}`);
} else {
envContent += `\n# Info@ Account OAuth Token\nINFO_REFRESH_TOKEN=${tokens.refresh_token}\n`;
}
fs.writeFileSync(ENV_PATH, envContent);
result.savedToEnv = true;
} catch (e) {
result.savedToEnv = false;
result.envError = e.message;
}
}
try {
const profile = await infoGmail.users.getProfile({ userId: 'me' });
result.email = profile.data.emailAddress;
result.ready = true;
} catch (e) {
result.ready = false;
result.testError = e.message;
}
res.json(result);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── Manual Code Exchange (for localhost redirect flow) ───
app.get('/api/exchange-code', async (req, res) => {
try {
const { code } = req.query;
if (!code) return res.status(400).json({ error: 'code parameter required' });
const { tokens } = await oauth2Client.getToken({ code, redirect_uri: OAUTH_REDIRECT });
oauth2Client.setCredentials(tokens);
gmail = google.gmail({ version: 'v1', auth: oauth2Client });
tasksApi = google.tasks({ version: 'v1', auth: oauth2Client });
const result = { success: true, hasRefreshToken: !!tokens.refresh_token };
if (tokens.refresh_token) {
result.refreshToken = tokens.refresh_token;
// Auto-save refresh token to .env
try {
let envContent = fs.readFileSync(ENV_PATH, 'utf8');
if (envContent.includes('GMAIL_REFRESH_TOKEN=')) {
envContent = envContent.replace(/GMAIL_REFRESH_TOKEN=.*/, `GMAIL_REFRESH_TOKEN=${tokens.refresh_token}`);
} else {
envContent += `\nGMAIL_REFRESH_TOKEN=${tokens.refresh_token}\n`;
}
fs.writeFileSync(ENV_PATH, envContent);
result.savedToEnv = true;
console.log(`[${AGENT_NAME}] Refresh token saved to ${ENV_PATH}`);
} catch (e) {
result.savedToEnv = false;
result.envError = e.message;
}
}
// Quick test
try {
const profile = await gmail.users.getProfile({ userId: 'me' });
result.email = profile.data.emailAddress;
result.gmailReady = true;
} catch (e) {
result.gmailReady = false;
result.testError = e.message;
}
res.json(result);
} catch (e) {
console.error(`[${AGENT_NAME}] Code exchange error:`, e.message);
res.status(500).json({ error: e.message });
}
});
// ─── Auth URL Generator (returns URL instead of redirecting) ───
app.get('/api/auth-url', (req, res) => {
if (!oauth2Client) return res.status(500).json({ error: 'OAuth client not configured' });
const url = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent',
redirect_uri: OAUTH_REDIRECT,
});
res.json({ authUrl: url, instructions: 'Visit this URL, approve access, then copy the full redirect URL from your browser address bar and pass the code parameter to /api/exchange-code?code=YOUR_CODE' });
});
// ─── Basic Auth (exempt health + OAuth routes) ───
app.use((req, res, next) => {
const publicApiPaths = [
'/health', '/api/health', '/auth', '/auth/info', '/auth/steve-office', '/auth/steve-personal', '/auth/agentabrams',
'/oauth2callback', '/oauth2callback-info',
'/api/exchange-code', '/api/exchange-code/info', '/api/exchange-code/steve-personal',
'/api/auth-url', '/api/auth-url/info', '/api/auth-url/steve-personal', '/api/auth-url/agentabrams'
];
if (publicApiPaths.includes(req.path)) return next();
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Basic ')) return res.status(401).json({ error: 'Auth required' });
const [user, pass] = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':');
// Single source of truth: same resolved credential as the primary middleware
// (the old separate GEORGE_BASIC_AUTH_PASS check caused the 2026-07 split-brain
// where no credential could pass both stacked middlewares once the vars diverged).
if (!_ctEq(user, _GEORGE_USER) || !_ctEq(pass, _GEORGE_PASS)) return res.status(401).json({ error: 'Invalid credentials' });
next();
});
// ─── Helper: decode email parts ───
function decodeBody(parts) {
if (!parts) return '';
for (const part of parts) {
if (part.mimeType === 'text/plain' && part.body?.data) {
return Buffer.from(part.body.data, 'base64url').toString('utf8');
}
if (part.parts) {
const nested = decodeBody(part.parts);
if (nested) return nested;
}
}
return '';
}
function getHeader(headers, name) {
const h = (headers || []).find(h => h.name.toLowerCase() === name.toLowerCase());
return h ? h.value : '';
}
// ─── API: Health Check ───
app.get('/api/health', (req, res) => {
res.json({
agent: AGENT_NAME,
codename: AGENT_CODENAME,
status: gmail ? 'ready' : 'no-gmail-client',
port: PORT,
hasCredentials: !!(GMAIL_CLIENT_ID && GMAIL_REFRESH_TOKEN),
uptime: process.uptime()
});
});
// ─── API: Status + Profile ───
app.get('/api/status', (req, res) => {
res.json({
agent: AGENT_NAME,
codename: AGENT_CODENAME,
color: AGENT_COLOR,
port: PORT,
status: gmail ? 'online' : 'error',
hasCredentials: !!(GMAIL_CLIENT_ID && GMAIL_REFRESH_TOKEN),
capabilities: ['list', 'read', 'search', 'send', 'labels', 'attachments'],
envSource: ENV_PATH
});
});
// ─── API: Get Profile (email address, history) ───
app.get('/api/profile', async (req, res) => {
try {
const { gmail: gm, key: account } = resolveAccount(req);
if (!gm) return res.status(400).json({ error: `Account "${account}" not configured` });
const profile = await gm.users.getProfile({ userId: 'me' });
res.json(profile.data);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Raw headers for a message (debug auth/SPF/DKIM/DMARC) ───
app.get('/api/messages/:id/headers', async (req, res) => {
try {
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
const r = await g.users.messages.get({ userId: 'me', id: req.params.id, format: 'metadata' });
const headers = (r.data.payload?.headers || []).reduce((acc, h) => {
const k = h.name;
acc[k] = acc[k] ? `${acc[k]}\n${h.value}` : h.value;
return acc;
}, {});
res.json({ id: r.data.id, labelIds: r.data.labelIds, headers });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Trash a message ───
app.post('/api/messages/:id/trash', async (req, res) => {
try {
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
const result = await g.users.messages.trash({ userId: 'me', id: req.params.id });
audit(account, 'trash', { messageId: req.params.id });
res.json({ success: true, messageId: result.data.id, account });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Batch trash by query ───
app.post('/api/messages/trash-by-query', async (req, res) => {
try {
const { q } = req.body;
if (!q) return res.status(400).json({ error: 'q (search query) required' });
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
const list = await g.users.messages.list({ userId: 'me', q, maxResults: 500 });
const ids = (list.data.messages || []).map(m => m.id);
if (ids.length === 0) return res.json({ success: true, trashed: 0, ids: [] });
await g.users.messages.batchModify({ userId: 'me', requestBody: { ids, addLabelIds: ['TRASH'], removeLabelIds: ['INBOX'] } });
audit(account, 'batch-trash', { q, count: ids.length });
res.json({ success: true, trashed: ids.length, ids, account });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: List Labels ───
app.get('/api/labels', async (req, res) => {
try {
const result = await gmail.users.labels.list({ userId: 'me' });
res.json(result.data.labels || []);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: List Messages ───
app.get('/api/messages', async (req, res) => {
try {
const { q, maxResults, labelIds, pageToken } = req.query;
const { gmail: gm, key: account } = resolveAccount(req);
if (!gm) return res.status(400).json({ error: `unknown account: ${account}` });
const params = { userId: 'me', maxResults: parseInt(maxResults) || 20 };
if (q) params.q = q;
if (labelIds) params.labelIds = labelIds.split(',');
if (pageToken) params.pageToken = pageToken;
const list = await gm.users.messages.list(params);
const messages = list.data.messages || [];
// Fetch headers for each message (batch)
const detailed = await Promise.all(
messages.slice(0, parseInt(maxResults) || 20).map(async m => {
try {
const full = await gm.users.messages.get({ userId: 'me', id: m.id, format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date', 'To'] });
return {
id: full.data.id,
threadId: full.data.threadId,
snippet: full.data.snippet,
labelIds: full.data.labelIds,
subject: getHeader(full.data.payload?.headers, 'Subject'),
from: getHeader(full.data.payload?.headers, 'From'),
to: getHeader(full.data.payload?.headers, 'To'),
date: getHeader(full.data.payload?.headers, 'Date'),
internalDate: full.data.internalDate
};
} catch { return { id: m.id, error: 'fetch-failed' }; }
})
);
res.json({
messages: detailed,
nextPageToken: list.data.nextPageToken,
resultSizeEstimate: list.data.resultSizeEstimate
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Read Single Message ───
app.get('/api/messages/:id', async (req, res) => {
try {
const { gmail: gm, key: account } = resolveAccount(req);
if (!gm) return res.status(400).json({ error: `unknown account: ${account}` });
const msg = await gm.users.messages.get({ userId: 'me', id: req.params.id, format: 'full' });
const headers = msg.data.payload?.headers || [];
const body = msg.data.payload?.body?.data
? Buffer.from(msg.data.payload.body.data, 'base64url').toString('utf8')
: decodeBody(msg.data.payload?.parts);
const attachments = [];
function findAttachments(parts) {
if (!parts) return;
for (const p of parts) {
if (p.filename && p.body?.attachmentId) {
attachments.push({ filename: p.filename, mimeType: p.mimeType, size: p.body.size, attachmentId: p.body.attachmentId });
}
if (p.parts) findAttachments(p.parts);
}
}
findAttachments(msg.data.payload?.parts);
res.json({
id: msg.data.id,
threadId: msg.data.threadId,
labelIds: msg.data.labelIds,
snippet: msg.data.snippet,
subject: getHeader(headers, 'Subject'),
from: getHeader(headers, 'From'),
to: getHeader(headers, 'To'),
cc: getHeader(headers, 'Cc'),
date: getHeader(headers, 'Date'),
body,
attachments,
internalDate: msg.data.internalDate
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Fetch attachment → save to /tmp (normal George auth) ───
// Added 2026-07-07 (Steve-authorized): pull a Gmail attachment by filename
// substring and write it under /tmp for local processing. Goes through the
// standard Basic-Auth gate like every other /api route; only writes to /tmp/.
app.get('/api/attachment-local', async (req, res) => {
try {
const { messageId, filename, save } = req.query;
if (!messageId || !filename || !save) return res.status(400).json({ error: 'messageId, filename, save required' });
const savePath = require('path').resolve(String(save));
if (!savePath.startsWith('/tmp/')) return res.status(400).json({ error: 'save path must be under /tmp/' });
const { gmail: gm, key: account } = resolveAccount(req);
if (!gm) return res.status(400).json({ error: `unknown account: ${account}` });
const msg = await gm.users.messages.get({ userId: 'me', id: String(messageId), format: 'full' });
let match = null;
(function walk(parts) {
if (!parts || match) return;
for (const p of parts) {
if (p.filename && p.body?.attachmentId && p.filename.toLowerCase().includes(String(filename).toLowerCase())) { match = p; return; }
if (p.parts) walk(p.parts);
}
})(msg.data.payload?.parts);
if (!match) return res.status(404).json({ error: `no attachment matching "${filename}"` });
const att = await gm.users.messages.attachments.get({ userId: 'me', messageId: String(messageId), id: match.body.attachmentId });
const buf = Buffer.from(att.data.data, 'base64url');
require('fs').writeFileSync(savePath, buf);
audit(account, 'attachment-local', { messageId, filename: match.filename, bytes: buf.length, save: savePath });
res.json({ saved: savePath, filename: match.filename, mimeType: match.mimeType, bytes: buf.length });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Search Messages ───
app.get('/api/search', async (req, res) => {
try {
const { q, maxResults } = req.query;
if (!q) return res.status(400).json({ error: 'Query (q) required' });
const { gmail: gm, key: account } = resolveAccount(req);
if (!gm) return res.status(400).json({ error: `unknown account: ${account}` });
const list = await gm.users.messages.list({ userId: 'me', q, maxResults: parseInt(maxResults) || 10 });
const messages = list.data.messages || [];
const detailed = await Promise.all(
messages.map(async m => {
try {
const full = await gm.users.messages.get({ userId: 'me', id: m.id, format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date'] });
return {
id: full.data.id,
snippet: full.data.snippet,
subject: getHeader(full.data.payload?.headers, 'Subject'),
from: getHeader(full.data.payload?.headers, 'From'),
date: getHeader(full.data.payload?.headers, 'Date')
};
} catch { return { id: m.id, error: 'fetch-failed' }; }
})
);
res.json({ query: q, messages: detailed, total: list.data.resultSizeEstimate });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Send Email ───
// RFC 2047 encoded-word for any header containing non-ASCII (em-dash, curly quotes, accented chars, etc.)
function encodeMimeHeader(s) {
if (!s) return s;
// eslint-disable-next-line no-control-regex
return /[^\x00-\x7F]/.test(s)
? `=?UTF-8?B?${Buffer.from(s, 'utf8').toString('base64')}?=`
: s;
}
// Tag every outbound body with a "where-it's-from" header — so Steve can spot
// at a glance which job/script/agent triggered the email. Source comes from
// req.body.source (preferred), req.body.job, req.headers['x-george-source'],
// or falls back to the user-agent string.
function inferSource(body, req) {
if (body && (body.no_source_tag || body.noSourceTag)) return ''; // opt out of the From-job banner
if (req?.headers?.['x-george-no-tag']) return '';
const raw = body.source || body.job || body.from_job || body.origin
|| req?.headers?.['x-george-source']
|| req?.headers?.['user-agent']
|| 'unspecified';
return String(raw).slice(0, 120);
}
function withSourceFooter(htmlBody, source) {
if (!source) return htmlBody || ''; // empty source -> no banner (customer-facing send)
const ts = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
const tag = `<div style="font-family:ui-monospace,Menlo,monospace;font-size:10px;letter-spacing:.10em;text-transform:uppercase;color:#7a766f;padding:8px 12px;background:rgba(212,160,74,0.08);border-left:3px solid #d4a04a;margin-bottom:18px;border-radius:2px"><strong style="color:#d4a04a">From job:</strong> ${escapeHtml(source)} <span style="color:#a8a39c">·</span> ${ts}</div>`;
return tag + (htmlBody || '');
}
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); }
// Build a UTF-8-clean RFC 2822 message: encoded subject, MIME-Version, base64 body (76-char lines per RFC 2045).
// inReplyTo / references are RFC Message-Id values (Gmail returns these from messages.get headers).
function buildRawMessage({ from, to, cc, bcc, subject, body, inReplyTo, references }) {
const bodyB64 = Buffer.from(body, 'utf8').toString('base64').replace(/(.{76})/g, '$1\r\n');
const refsHeader = references && references.length ? references.join(' ') : (inReplyTo || null);
const headerLines = [
from ? `From: ${from}` : null,
to ? `To: ${to}` : null,
cc ? `Cc: ${cc}` : null,
bcc ? `Bcc: ${bcc}` : null,
`Subject: ${encodeMimeHeader(subject)}`,
inReplyTo ? `In-Reply-To: ${inReplyTo}` : null,
refsHeader ? `References: ${refsHeader}` : null,
'MIME-Version: 1.0',
'Content-Type: text/html; charset=UTF-8',
'Content-Transfer-Encoding: base64',
].filter(Boolean);
const message = headerLines.join('\r\n') + '\r\n\r\n' + bodyB64;
return Buffer.from(message, 'utf8').toString('base64url');
}
// Look up RFC Message-Id + thread for a previously-sent message id, so callers
// can reply in-thread by passing only the gmail message id (not the raw header).
async function resolveReplyContext(gmail, replyToMessageId) {
const m = await gmail.users.messages.get({
userId: 'me', id: replyToMessageId, format: 'metadata',
metadataHeaders: ['Message-Id', 'References', 'Subject'],
});
const headers = m.data.payload?.headers || [];
const find = (n) => headers.find(h => h.name.toLowerCase() === n.toLowerCase())?.value || null;
const messageIdHdr = find('Message-Id');
const refs = find('References');
const subject = find('Subject') || '';
return {
threadId: m.data.threadId,
messageIdHeader: messageIdHdr,
referencesHeader: [refs, messageIdHdr].filter(Boolean).join(' ').trim() || null,
subject,
};
}
// ── External-send guard (2026-06-17) ─────────────────────────────────────────
// Incident: an autonomous worker drained gated vendor-email memos and auto-sent
// them via George. Fix at the chokepoint — sends to Steve-owned addresses are
// always allowed; any send to an EXTERNAL recipient requires a human-approval
// token (header X-Send-Approval, or body.send_approval_token). Fail-closed.
const EXT_SEND_TOKEN = (() => {
if (process.env.GEORGE_EXTERNAL_SEND_TOKEN) return process.env.GEORGE_EXTERNAL_SEND_TOKEN;
try { const m = require('fs').readFileSync(require('path').join(__dirname, '.env'), 'utf8').match(/^GEORGE_EXTERNAL_SEND_TOKEN=(.+)$/m); return m ? m[1].trim() : ''; } catch (e) { return ''; }
})();
// read a key from .env directly (launchd doesn't load .env into process.env;
// same fallback the EXT_SEND_TOKEN above uses) so the allowlist is configurable
function _envFile(key) { try { const m = require('fs').readFileSync(require('path').join(__dirname, '.env'), 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')); return m ? m[1].trim() : ''; } catch (e) { return ''; } }
const INTERNAL_DOMAINS = (process.env.GEORGE_INTERNAL_DOMAINS || _envFile('GEORGE_INTERNAL_DOMAINS') || 'designerwallcoverings.com').toLowerCase().split(',').map(s => s.trim()).filter(Boolean);
const INTERNAL_ADDRS = (process.env.GEORGE_INTERNAL_ADDRS || _envFile('GEORGE_INTERNAL_ADDRS') || 'steveabramsdesigns@gmail.com,theagentabrams@gmail.com,wallsandfabrics@gmail.com').toLowerCase().split(',').map(s => s.trim()).filter(Boolean);
function _recips(...vals) { const out = []; for (const v of vals) { if (!v) continue; const arr = Array.isArray(v) ? v : String(v).split(','); for (const a of arr) { const m = String(a).match(/[\w.+-]+@[\w.-]+\.\w+/); if (m) out.push(m[0].toLowerCase()); } } return out; }
function _isInternal(addr) { const dom = (addr.split('@')[1] || ''); return INTERNAL_ADDRS.includes(addr) || INTERNAL_DOMAINS.includes(dom); }
function externalSendGuard(req) {
const b = req.body || {};
const external = _recips(b.to, b.cc, b.bcc).filter(a => !_isInternal(a));
if (!external.length) return { ok: true };
const got = (req.get && req.get('x-send-approval')) || b.send_approval_token || '';
if (EXT_SEND_TOKEN && got && got === EXT_SEND_TOKEN) return { ok: true, external };
return { ok: false, external, reason: (EXT_SEND_TOKEN
? 'external send blocked — missing/invalid human-approval token (pass X-Send-Approval). '
: 'external send blocked — GEORGE_EXTERNAL_SEND_TOKEN not configured (fail-closed). ')
+ 'External recipients: ' + external.join(', ') };
}
app.post('/api/send', async (req, res) => {
try {
const { from, to, subject, body, cc, bcc, threadId: bodyThreadId, inReplyTo: bodyInReplyTo, replyToMessageId, references } = req.body;
if (!to || !body) return res.status(400).json({ error: 'to and body required' });
{ const _g = externalSendGuard(req); if (!_g.ok) { try { audit(req.body.account || '?', 'send-BLOCKED', { to, external: _g.external, reason: _g.reason }); } catch (e) {} return res.status(403).json({ error: _g.reason, blocked: true, external: _g.external }); } }
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
let threadId = bodyThreadId || null;
let inReplyTo = bodyInReplyTo || null;
let refsArr = Array.isArray(references) ? references.slice() : null;
let finalSubject = subject;
// Convenience: caller supplied just the gmail message id of what to reply to → we resolve everything.
if (replyToMessageId) {
const ctx = await resolveReplyContext(g, replyToMessageId);
threadId = threadId || ctx.threadId;
inReplyTo = inReplyTo || ctx.messageIdHeader;
if (!refsArr && ctx.referencesHeader) refsArr = ctx.referencesHeader.split(/\s+/);
if (!finalSubject) finalSubject = ctx.subject?.startsWith('Re:') ? ctx.subject : `Re: ${ctx.subject || ''}`.trim();
}
if (!finalSubject) return res.status(400).json({ error: 'subject required (or pass replyToMessageId)' });
const source = inferSource(req.body, req);
const taggedBody = withSourceFooter(body, source);
const encoded = buildRawMessage({ from, to, cc, bcc, subject: finalSubject, body: taggedBody, inReplyTo, references: refsArr });
const requestBody = { raw: encoded };
if (threadId) requestBody.threadId = threadId;
const result = await g.users.messages.send({ userId: 'me', requestBody });
audit(account, 'send', { from, to, subject: finalSubject, source, messageId: result.data.id, threadId: result.data.threadId, inReplyTo: inReplyTo || null });
res.json({ success: true, messageId: result.data.id, threadId: result.data.threadId, account, source, inThread: Boolean(threadId || inReplyTo) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: List Send-As aliases ───
app.get('/api/aliases', async (req, res) => {
try {
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
const result = await g.users.settings.sendAs.list({ userId: 'me' });
res.json({ account, aliases: result.data.sendAs || [] });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Create Send-As alias (triggers verification email) ───
// Body: { sendAsEmail, displayName?, treatAsAlias?, smtp?: {host,port,username,password,securityMode} }
// Without smtp, Gmail relays through its own servers (sender shown as "via gmail.com" in some clients).
// With smtp, relays through that SMTP server (cleaner DKIM-from-source-domain look).
app.post('/api/aliases/create', async (req, res) => {
try {
const { sendAsEmail, displayName, treatAsAlias, smtp } = req.body || {};
if (!sendAsEmail) return res.status(400).json({ error: 'sendAsEmail required' });
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
const requestBody = { sendAsEmail, treatAsAlias: treatAsAlias !== false };
if (displayName) requestBody.displayName = displayName;
if (smtp && smtp.host) {
requestBody.smtpMsa = {
host: smtp.host,
port: smtp.port || 465,
username: smtp.username,
password: smtp.password,
securityMode: smtp.securityMode || 'ssl',
};
}
const result = await g.users.settings.sendAs.create({ userId: 'me', requestBody });
audit(account, 'alias-create', { sendAsEmail, smtp: !!smtp });
res.json({ success: true, account, alias: { sendAsEmail: result.data.sendAsEmail, verificationStatus: result.data.verificationStatus, isPrimary: result.data.isPrimary, treatAsAlias: result.data.treatAsAlias } });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Verify Send-As alias by extracting verify link from inbox + fetching it ───
// Body: { sendAsEmail, account: "main"|"info" } — searches for the most recent verification mail and clicks it
app.post('/api/aliases/verify-from-inbox', async (req, res) => {
try {
const { sendAsEmail } = req.body || {};
if (!sendAsEmail) return res.status(400).json({ error: 'sendAsEmail required' });
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
// Search for the most recent verification email related to this address
const q = `from:noreply-confirmation@google.com OR from:gmail-noreply@google.com to:${sendAsEmail} subject:Confirmation newer_than:1d`;
const list = await g.users.messages.list({ userId: 'me', q, maxResults: 5 });
const messages = list.data.messages || [];
if (messages.length === 0) {
// Fallback: any subaddress version of the from address
const m = sendAsEmail.match(/^([^@]+)@(.+)$/);
if (m) {
const tagged = `steve+${m[2].replace(/\./g, '-')}@designerwallcoverings.com`;
const q2 = `to:${tagged} subject:Confirmation newer_than:1d`;
const r2 = await g.users.messages.list({ userId: 'me', q: q2, maxResults: 5 });
if (r2.data.messages) messages.push(...r2.data.messages);
}
}
if (messages.length === 0) return res.status(404).json({ error: 'no verification email found in last 24h', sendAsEmail });
// Get the most recent message body
const msg = await g.users.messages.get({ userId: 'me', id: messages[0].id, format: 'full' });
function findUrl(payload) {
if (!payload) return null;
if (payload.body && payload.body.data) {
const text = Buffer.from(payload.body.data, 'base64url').toString('utf8');
const urls = text.match(/https?:\/\/[^\s)>"]*ConfirmAddress[^\s)>"]*/g) || text.match(/https?:\/\/mail-settings\.google\.com[^\s)>"]+/g);
if (urls) return urls[0];
}
if (payload.parts) for (const p of payload.parts) { const u = findUrl(p); if (u) return u; }
return null;
}
const url = findUrl(msg.data.payload);
if (!url) return res.status(404).json({ error: 'no verify URL in message', messageId: messages[0].id });
// Fetch URL = clicks verify
const r = await fetch(url, { redirect: 'follow' }).catch(e => ({ ok: false, status: 0, error: e.message }));
audit(account, 'alias-verify-clicked', { sendAsEmail, status: r.status });
res.json({ success: true, account, sendAsEmail, verifyUrl: url.slice(0, 80) + '...', clickStatus: r.status });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Configure Send-As alias smtpMsa to use PurelyMail SMTP ───
// Body: { sendAsEmail: "info@<domain>" }
// Uses relay@hospitalitywallcoverings.com PM SMTP creds (password from /root/.purelymail-relay-pw)
app.post('/api/aliases/configure-smtp', async (req, res) => {
try {
const { sendAsEmail } = req.body;
if (!sendAsEmail) return res.status(400).json({ error: 'sendAsEmail required' });
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
let pmPass;
try { pmPass = fs.readFileSync('/root/.purelymail-relay-pw', 'utf8').trim(); }
catch { return res.status(500).json({ error: 'PM relay password file missing on server' }); }
const result = await g.users.settings.sendAs.update({
userId: 'me',
sendAsEmail,
requestBody: {
sendAsEmail,
treatAsAlias: true,
smtpMsa: {
host: 'smtp.purelymail.com',
port: 465,
username: 'relay@hospitalitywallcoverings.com',
password: pmPass,
securityMode: 'ssl'
}
}
});
audit(account, 'configure-smtp', { sendAsEmail });
// Don't return the password back — Gmail's response also has it as null
res.json({ success: true, account, sendAsEmail, smtpMsa: result.data.smtpMsa, treatAsAlias: result.data.treatAsAlias });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Send with Attachments ───
// Body: { to, cc?, bcc?, subject, body (html), attachments: [{ filename, content_base64, mime_type? }], account?: "main"|"info" }
app.post('/api/send-with-attachment', async (req, res) => {
try {
const { to, cc, bcc, subject, body, attachments, account } = req.body;
if (!to || !subject || !body) return res.status(400).json({ error: 'to, subject, and body required' });
if (!attachments || !Array.isArray(attachments) || attachments.length === 0) {
return res.status(400).json({ error: 'attachments array required' });
}
{ const _g = externalSendGuard(req); if (!_g.ok) { try { audit(account || '?', 'send-BLOCKED', { to, external: _g.external, reason: _g.reason }); } catch (e) {} return res.status(403).json({ error: _g.reason, blocked: true, external: _g.external }); } }
// Resolve any account (steve-office / info / steve-personal / agentabrams),
// matching /api/send — not just office/info.
const { gmail: gmailClient, oauth: authClient, key: acctKey } = resolveAccount(req);
if (!authClient || !gmailClient) {
return res.status(500).json({ error: `account not configured: ${acctKey}` });
}
// Build MIME with nodemailer (handles UTF-8 subjects, multipart, base64 chunking correctly)
const profile = await gmailClient.users.getProfile({ userId: 'me' });
const fromAddr = profile.data.emailAddress;
const source = inferSource(req.body, req);
const taggedBody = withSourceFooter(body, source);
const mailOpts = {
from: fromAddr,
to,
subject,
html: taggedBody,
attachments: attachments.map(a => ({
filename: a.filename || 'attachment.bin',
content: Buffer.from(a.content_base64, 'base64'),
contentType: a.mime_type || 'application/octet-stream',
})),
};
if (cc) mailOpts.cc = cc;
if (bcc) mailOpts.bcc = bcc;
const mime = await new Promise((resolve, reject) => {
const mail = nodemailer.createTransport({ streamTransport: true, buffer: true }).sendMail(
mailOpts,
(err, info) => (err ? reject(err) : resolve(info.message))
);
});
const raw = Buffer.from(mime).toString('base64url');
const result = await gmailClient.users.messages.send({ userId: 'me', requestBody: { raw } });
res.json({
success: true,
messageId: result.data.id,
threadId: result.data.threadId,
from: fromAddr,
account: acctKey,
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Create Draft ───
app.post('/api/drafts', async (req, res) => {
try {
const { to, subject, body, cc, bcc } = req.body;
if (!subject || !body) return res.status(400).json({ error: 'subject and body required' });
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
const source = inferSource(req.body, req);
const taggedBody = withSourceFooter(body, source);
const encoded = buildRawMessage({ to, cc, bcc, subject, body: taggedBody });
const result = await g.users.drafts.create({ userId: 'me', requestBody: { message: { raw: encoded } } });
audit(account, 'draft', { to, subject, source, draftId: result.data.id });
res.json({ success: true, draftId: result.data.id, messageId: result.data.message?.id, account, source });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: List Drafts ───
app.get('/api/drafts', async (req, res) => {
try {
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
const result = await g.users.drafts.list({ userId: 'me', maxResults: parseInt(req.query.maxResults) || 10 });
res.json(result.data.drafts || []);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Delete a Draft ───
// DELETE /api/drafts/:id?account=info — permanently removes the draft (not trash).
app.delete('/api/drafts/:id', async (req, res) => {
try {
const { gmail: g, key: account } = resolveAccount(req);
if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
await g.users.drafts.delete({ userId: 'me', id: req.params.id });
audit(account, 'draft-delete', { draftId: req.params.id });
res.json({ success: true, draftId: req.params.id, account });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── API: Get Attachment (account-aware, raw binary) ───
// Fixed 2026-07-10: was hardcoded to the steve-office `gmail` client AND
// returned base64 JSON. Cross-account requests (?account=info) failed with a
// 500 "Permission denied" because steve-office can't read an info@ message id,
// and the MCP bridge (georgeRaw → arrayBuffer) expects raw bytes, not JSON.
// Now resolves the correct per-account client and streams raw binary like the
// /api/info/... route does, so the MCP writes correct file bytes for any account.
app.get('/api/messages/:messageId/attachments/:attachmentId', async (req, res) => {
try {
const { key, gmail: gm } = resolveAccount(req);
if (!gm) return res.status(503).json({ error: `Gmail not initialized for account '${key}' — authorize via /auth/${key}` });
const att = await gm.users.messages.attachments.get({
userId: 'me', messageId: req.params.messageId, id: req.params.attachmentId
});
const buf = Buffer.from(att.data.data, 'base64url');
res.set('Content-Length', buf.length);
res.type('application/octet-stream');
res.send(buf);
} catch (e) {
console.error('[attachment] error:', e?.code, e?.message, JSON.stringify(e?.errors || e?.response?.data || {}));
res.status(500).json({ error: e.message, code: e?.code, detail: e?.errors || e?.response?.data?.error || null });
}
});
// ─── Slack Webhook for notifications ───
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL || '';
function slackNotify(text) {
try {
const https = require('https');
const url = new URL(SLACK_WEBHOOK);
const payload = JSON.stringify({ text });
const req = https.request({ hostname: url.hostname, path: url.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } });
req.write(payload);
req.end();
} catch {}
}
// ─── Google Tasks API ───
app.get('/api/tasks/lists', async (req, res) => {
try {
if (!tasksApi) return res.status(503).json({ error: 'Tasks API not initialized — re-authorize at /auth' });
const result = await tasksApi.tasklists.list({ maxResults: 100 });
res.json({ lists: result.data.items || [] });
} catch (e) {
if (e.message.includes('insufficient')) {
return res.status(403).json({ error: 'Tasks scope not authorized. Visit /auth to grant permission.', authUrl: '/auth' });
}
res.status(500).json({ error: e.message });
}
});
app.get('/api/tasks/:listId', async (req, res) => {
try {
if (!tasksApi) return res.status(503).json({ error: 'Tasks API not initialized — re-authorize at /auth' });
const showCompleted = req.query.showCompleted !== 'false';
const showHidden = req.query.showHidden === 'true';
const result = await tasksApi.tasks.list({
tasklist: req.params.listId,
maxResults: parseInt(req.query.maxResults) || 100,
showCompleted,
showHidden,
});
const tasks = (result.data.items || []).map(t => ({
id: t.id,
title: t.title,
notes: t.notes || null,
status: t.status,
due: t.due || null,
completed: t.completed || null,
updated: t.updated,
parent: t.parent || null,
position: t.position,
links: t.links || [],
}));
res.json({ listId: req.params.listId, count: tasks.length, tasks });
} catch (e) {
if (e.message.includes('insufficient')) {
return res.status(403).json({ error: 'Tasks scope not authorized. Visit /auth to grant permission.', authUrl: '/auth' });
}
res.status(500).json({ error: e.message });
}
});
// Convenience: get all tasks across all lists
app.get('/api/tasks', async (req, res) => {
try {
if (!tasksApi) return res.status(503).json({ error: 'Tasks API not initialized — re-authorize at /auth' });
const listsResult = await tasksApi.tasklists.list({ maxResults: 100 });
const lists = listsResult.data.items || [];
const allTasks = [];
for (const list of lists) {
const tasksResult = await tasksApi.tasks.list({
tasklist: list.id,
maxResults: 100,
showCompleted: req.query.showCompleted !== 'false',
});
const items = (tasksResult.data.items || []).map(t => ({
id: t.id,
title: t.title,
notes: t.notes || null,
status: t.status,
due: t.due || null,
completed: t.completed || null,
updated: t.updated,
listId: list.id,
listTitle: list.title,
}));
allTasks.push(...items);
}
res.json({ totalLists: lists.length, totalTasks: allTasks.length, tasks: allTasks });
} catch (e) {
if (e.message.includes('insufficient')) {
return res.status(403).json({ error: 'Tasks scope not authorized. Visit /auth to grant permission.', authUrl: '/auth' });
}
res.status(500).json({ error: e.message });
}
});
// ─── Info@ Tasks API ───
app.get('/api/info/tasks/lists', async (req, res) => {
try {
if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized — authorize at /auth/info', authUrl: '/api/auth-url/info' });
const result = await infoTasksApi.tasklists.list({ maxResults: 100 });
res.json({ account: 'info@designerwallcoverings.com', lists: result.data.items || [] });
} catch (e) {
if (e.message.includes('insufficient')) {
return res.status(403).json({ error: 'Info@ tasks scope not authorized. Visit /auth/info.', authUrl: '/auth/info' });
}
res.status(500).json({ error: e.message });
}
});
app.get('/api/info/tasks/:listId', async (req, res) => {
try {
if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized — authorize at /auth/info', authUrl: '/api/auth-url/info' });
const showCompleted = req.query.showCompleted !== 'false';
const showHidden = req.query.showHidden === 'true';
const result = await infoTasksApi.tasks.list({
tasklist: req.params.listId,
maxResults: parseInt(req.query.maxResults) || 100,
showCompleted,
showHidden,
});
const tasks = (result.data.items || []).map(t => ({
id: t.id,
title: t.title,
notes: t.notes || null,
status: t.status,
due: t.due || null,
completed: t.completed || null,
updated: t.updated,
parent: t.parent || null,
position: t.position,
links: t.links || [],
}));
res.json({ account: 'info@designerwallcoverings.com', listId: req.params.listId, count: tasks.length, tasks });
} catch (e) {
if (e.message.includes('insufficient')) {
return res.status(403).json({ error: 'Info@ tasks scope not authorized.', authUrl: '/auth/info' });
}
res.status(500).json({ error: e.message });
}
});
app.get('/api/info/tasks', async (req, res) => {
try {
if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized — authorize at /auth/info', authUrl: '/api/auth-url/info' });
const listsResult = await infoTasksApi.tasklists.list({ maxResults: 100 });
const lists = listsResult.data.items || [];
const allTasks = [];
for (const list of lists) {
const tasksResult = await infoTasksApi.tasks.list({
tasklist: list.id,
maxResults: 100,
showCompleted: req.query.showCompleted !== 'false',
});
const items = (tasksResult.data.items || []).map(t => ({
id: t.id,
title: t.title,
notes: t.notes || null,
status: t.status,
due: t.due || null,
completed: t.completed || null,
updated: t.updated,
listId: list.id,
listTitle: list.title,
}));
allTasks.push(...items);
}
res.json({ account: 'info@designerwallcoverings.com', totalLists: lists.length, totalTasks: allTasks.length, tasks: allTasks });
} catch (e) {
if (e.message.includes('insufficient')) {
return res.status(403).json({ error: 'Info@ tasks scope not authorized.', authUrl: '/auth/info' });
}
res.status(500).json({ error: e.message });
}
});
// ─── Info@ Tasks Write Endpoints ───
// Create a new task list
app.post('/api/info/tasks/lists/create', async (req, res) => {
try {
if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized', authUrl: '/auth/info' });
const { title } = req.body;
if (!title) return res.status(400).json({ error: 'title is required' });
const result = await infoTasksApi.tasklists.insert({ requestBody: { title } });
res.json({ success: true, list: result.data });
} catch (e) {
if (e.message.includes('insufficient')) {
return res.status(403).json({ error: 'Tasks write scope not authorized. Visit /auth/info to upgrade from tasks.readonly to tasks.', authUrl: '/auth/info' });
}
res.status(500).json({ error: e.message });
}
});
// Find a task list by name, or create it if it doesn't exist
app.post('/api/info/tasks/lists/find-or-create', async (req, res) => {
try {
if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized', authUrl: '/auth/info' });
const { title } = req.body;
if (!title) return res.status(400).json({ error: 'title is required' });
// Search existing lists
const listsResult = await infoTasksApi.tasklists.list({ maxResults: 100 });
const existing = (listsResult.data.items || []).find(l => l.title === title);
if (existing) {
return res.json({ success: true, created: false, list: existing });
}
// Create new list
const result = await infoTasksApi.tasklists.insert({ requestBody: { title } });
res.json({ success: true, created: true, list: result.data });
} catch (e) {
if (e.message.includes('insufficient')) {
return res.status(403).json({ error: 'Tasks write scope not authorized. Visit /auth/info.', authUrl: '/auth/info' });
}
res.status(500).json({ error: e.message });
}
});
// Move a task from one list to another (insert into dest + delete from source)
// Google Tasks API has no cross-list move — we must copy then delete
app.post('/api/info/tasks/move', async (req, res) => {
try {
if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized', authUrl: '/auth/info' });
const { sourceListId, taskId, destListId, markCompleted } = req.body;
if (!sourceListId || !taskId || !destListId) {
return res.status(400).json({ error: 'sourceListId, taskId, and destListId are required' });
}
// 1. Get the task from source list
const taskResult = await infoTasksApi.tasks.get({ tasklist: sourceListId, task: taskId });
const task = taskResult.data;
// 2. Insert into destination list (preserve title, notes, due, links)
const newTaskBody = {
title: task.title,
notes: task.notes || undefined,
due: task.due || undefined,
status: markCompleted ? 'completed' : 'needsAction',
};
const insertResult = await infoTasksApi.tasks.insert({ tasklist: destListId, requestBody: newTaskBody });
// 3. Delete from source list
await infoTasksApi.tasks.delete({ tasklist: sourceListId, task: taskId });
res.json({
success: true,
moved: true,
sourceListId,
destListId,
originalTaskId: taskId,
newTaskId: insertResult.data.id,
title: task.title,
});
} catch (e) {
if (e.message.includes('insufficient')) {
return res.status(403).json({ error: 'Tasks write scope not authorized. Visit /auth/info.', authUrl: '/auth/info' });
}
res.status(500).json({ error: e.message });
}
});
// Update a task's title (e.g., prepend "READY" status)
app.post('/api/info/tasks/:listId/:taskId/update', async (req, res) => {
try {
if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized', authUrl: '/auth/info' });
const { listId, taskId } = req.params;
const { title, notes } = req.body;
const body = {};
if (title) body.title = title;
if (notes !== undefined) body.notes = notes;
if (!Object.keys(body).length) return res.status(400).json({ error: 'title or notes required' });
const result = await infoTasksApi.tasks.patch({
tasklist: listId,
task: taskId,
requestBody: body
});
res.json({ success: true, task: result.data });
} catch (e) {
if (e.message.includes('insufficient')) {
return res.status(403).json({ error: 'Tasks write scope not authorized. Visit /auth/info.', authUrl: '/auth/info' });
}
res.status(500).json({ error: e.message });
}
});
// Complete a task (mark as done without moving)
app.post('/api/info/tasks/:listId/:taskId/complete', async (req, res) => {
try {
if (!infoTasksApi) return res.status(503).json({ error: 'Info@ Tasks API not initialized', authUrl: '/auth/info' });
const { listId, taskId } = req.params;
const result = await infoTasksApi.tasks.patch({
tasklist: listId,
task: taskId,
requestBody: { status: 'completed' }
});
res.json({ success: true, task: result.data });
} catch (e) {
if (e.message.includes('insufficient')) {
return res.status(403).json({ error: 'Tasks write scope not authorized. Visit /auth/info.', authUrl: '/auth/info' });
}
res.status(500).json({ error: e.message });
}
});
// ─── Info@ Gmail Endpoints ───
app.get('/api/info/profile', async (req, res) => {
try {
if (!infoGmail) return res.status(503).json({ error: 'Info@ Gmail not initialized — authorize at /auth/info', authUrl: '/api/auth-url/info' });
const profile = await infoGmail.users.getProfile({ userId: 'me' });
res.json({ account: 'info@designerwallcoverings.com', ...profile.data });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/info/messages', async (req, res) => {
try {
if (!infoGmail) return res.status(503).json({ error: 'Info@ Gmail not initialized — authorize at /auth/info', authUrl: '/api/auth-url/info' });
const { q, maxResults, labelIds, pageToken } = req.query;
const params = { userId: 'me', maxResults: parseInt(maxResults) || 20 };
if (q) params.q = q;
if (labelIds) params.labelIds = labelIds.split(',');
if (pageToken) params.pageToken = pageToken;
const list = await infoGmail.users.messages.list(params);
const messages = list.data.messages || [];
const detailed = await Promise.all(
messages.slice(0, parseInt(maxResults) || 20).map(async m => {
try {
const full = await infoGmail.users.messages.get({ userId: 'me', id: m.id, format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date', 'To'] });
return {
id: full.data.id,
threadId: full.data.threadId,
snippet: full.data.snippet,
subject: getHeader(full.data.payload?.headers, 'Subject'),
from: getHeader(full.data.payload?.headers, 'From'),
to: getHeader(full.data.payload?.headers, 'To'),
date: getHeader(full.data.payload?.headers, 'Date'),
};
} catch { return { id: m.id, error: 'fetch-failed' }; }
})
);
res.json({ account: 'info@designerwallcoverings.com', messages: detailed, nextPageToken: list.data.nextPageToken });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── Info@ Google Drive Endpoints ───
const DRIVE_NOT_READY = { error: 'Info@ Drive not initialized — authorize at /auth/info (needs drive.readonly scope)', authUrl: '/auth/info' };
// List files (supports Drive query syntax in ?q= param)
app.get('/api/info/drive', async (req, res) => {
try {
if (!infoDrive) return res.status(503).json(DRIVE_NOT_READY);
const { q, maxResults, pageToken, orderBy } = req.query;
const params = {
pageSize: parseInt(maxResults) || 25,
fields: 'nextPageToken, files(id, name, mimeType, size, modifiedTime, createdTime, webViewLink, iconLink, parents, shared)',
orderBy: orderBy || 'modifiedTime desc',
};
if (q) params.q = q;
if (pageToken) params.pageToken = pageToken;
const result = await infoDrive.files.list(params);
res.json({ account: 'info@', files: result.data.files || [], nextPageToken: result.data.nextPageToken });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Get file metadata by ID
app.get('/api/info/drive/file/:fileId', async (req, res) => {
try {
if (!infoDrive) return res.status(503).json(DRIVE_NOT_READY);
const result = await infoDrive.files.get({
fileId: req.params.fileId,
fields: 'id, name, mimeType, size, modifiedTime, createdTime, webViewLink, webContentLink, iconLink, parents, shared, description',
});
res.json(result.data);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Search files by name
app.get('/api/info/drive/search/:query', async (req, res) => {
try {
if (!infoDrive) return res.status(503).json(DRIVE_NOT_READY);
const searchQ = `name contains '${req.params.query.replace(/'/g, "\\'")}'`;
const result = await infoDrive.files.list({
q: searchQ,
pageSize: parseInt(req.query.maxResults) || 25,
fields: 'files(id, name, mimeType, size, modifiedTime, webViewLink, parents)',
orderBy: 'modifiedTime desc',
});
res.json({ account: 'info@', query: req.params.query, files: result.data.files || [] });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// List shared drives
app.get('/api/info/drive/shared', async (req, res) => {
try {
if (!infoDrive) return res.status(503).json(DRIVE_NOT_READY);
const result = await infoDrive.drives.list({ pageSize: 50 });
res.json({ account: 'info@', drives: result.data.drives || [] });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── Info@ Single Message (with HTML body) ───
function decodeHtmlBody(parts) {
if (!parts) return { html: '', text: '' };
let html = '', text = '';
for (const part of parts) {
if (part.mimeType === 'text/html' && part.body?.data) {
html = Buffer.from(part.body.data, 'base64url').toString('utf8');
}
if (part.mimeType === 'text/plain' && part.body?.data) {
text = Buffer.from(part.body.data, 'base64url').toString('utf8');
}
if (part.parts) {
const nested = decodeHtmlBody(part.parts);
if (nested.html && !html) html = nested.html;
if (nested.text && !text) text = nested.text;
}
}
return { html, text };
}
app.get('/api/info/messages/:id', async (req, res) => {
try {
if (!infoGmail) return res.status(503).json({ error: 'Info@ Gmail not initialized', authUrl: '/auth/info' });
const msg = await infoGmail.users.messages.get({ userId: 'me', id: req.params.id, format: 'full' });
const headers = msg.data.payload?.headers || [];
// Decode both HTML and text bodies
const topBody = msg.data.payload?.body?.data
? Buffer.from(msg.data.payload.body.data, 'base64url').toString('utf8')
: null;
const { html, text } = decodeHtmlBody(msg.data.payload?.parts);
const attachments = [];
function findAttachments(parts) {
if (!parts) return;
for (const p of parts) {
if (p.filename && p.body?.attachmentId) {
attachments.push({ filename: p.filename, mimeType: p.mimeType, size: p.body.size, attachmentId: p.body.attachmentId });
}
if (p.parts) findAttachments(p.parts);
}
}
findAttachments(msg.data.payload?.parts);
res.json({
id: msg.data.id,
threadId: msg.data.threadId,
labelIds: msg.data.labelIds,
snippet: msg.data.snippet,
subject: getHeader(headers, 'Subject'),
from: getHeader(headers, 'From'),
to: getHeader(headers, 'To'),
cc: getHeader(headers, 'Cc'),
date: getHeader(headers, 'Date'),
bodyHtml: html || topBody || '',
bodyText: text || topBody || '',
attachments,
internalDate: msg.data.internalDate
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── Info@ Search Messages ───
app.get('/api/info/search', async (req, res) => {
try {
if (!infoGmail) return res.status(503).json({ error: 'Info@ Gmail not initialized', authUrl: '/auth/info' });
const { q, maxResults } = req.query;
if (!q) return res.status(400).json({ error: 'Query (q) required' });
const list = await infoGmail.users.messages.list({ userId: 'me', q, maxResults: parseInt(maxResults) || 10 });
const messages = list.data.messages || [];
const detailed = await Promise.all(
messages.map(async m => {
try {
const full = await infoGmail.users.messages.get({ userId: 'me', id: m.id, format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date', 'To'] });
return {
id: full.data.id,
snippet: full.data.snippet,
subject: getHeader(full.data.payload?.headers, 'Subject'),
from: getHeader(full.data.payload?.headers, 'From'),
to: getHeader(full.data.payload?.headers, 'To'),
date: getHeader(full.data.payload?.headers, 'Date')
};
} catch { return { id: m.id, error: 'fetch-failed' }; }
})
);
res.json({ account: 'info@designerwallcoverings.com', query: q, messages: detailed, total: list.data.resultSizeEstimate });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── Info@ Download Attachment ───
app.get('/api/info/messages/:messageId/attachments/:attachmentId', async (req, res) => {
try {
if (!infoGmail) return res.status(503).json({ error: 'Info@ Gmail not initialized', authUrl: '/auth/info' });
const att = await infoGmail.users.messages.attachments.get({
userId: 'me', messageId: req.params.messageId, id: req.params.attachmentId
});
const buf = Buffer.from(att.data.data, 'base64');
res.set('Content-Length', buf.length);
res.send(buf);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ══════════════════════════════════════════════════════════════
// ─── Unified Google Workspace API (per-account) ───
// Endpoints below accept ?account=steve-office|info|steve-personal
// Default account = steve-office.
// ══════════════════════════════════════════════════════════════
function requireService(svc, svcName, account, res) {
if (!svc) {
res.status(503).json({ error: `${svcName} not ready for account '${account}' — authorize via /auth/${account}` });
return false;
}
return true;
}
// ─── Google Docs ───
app.post('/api/docs/create', async (req, res) => {
try {
const { docs: d, key, label } = resolveAccount(req);
if (!requireService(d, 'Docs', key, res)) return;
const { title = 'Untitled' } = req.body || {};
const result = await d.documents.create({ requestBody: { title } });
audit(key, 'docs.create', { documentId: result.data.documentId, title });
res.json({ account: key, email: label, documentId: result.data.documentId, title, url: `https://docs.google.com/document/d/${result.data.documentId}/edit` });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/docs/:id', async (req, res) => {
try {
const { docs: d, key, label } = resolveAccount(req);
if (!requireService(d, 'Docs', key, res)) return;
const result = await d.documents.get({ documentId: req.params.id });
res.json({ account: key, email: label, document: result.data });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/docs/:id/append', async (req, res) => {
try {
const { docs: d, key } = resolveAccount(req);
if (!requireService(d, 'Docs', key, res)) return;
const { text = '' } = req.body || {};
// Get end index
const meta = await d.documents.get({ documentId: req.params.id });
const endIndex = meta.data.body?.content?.slice(-1)[0]?.endIndex || 1;
await d.documents.batchUpdate({
documentId: req.params.id,
requestBody: { requests: [{ insertText: { location: { index: Math.max(endIndex - 1, 1) }, text } }] }
});
audit(key, 'docs.append', { documentId: req.params.id, length: text.length });
res.json({ account: key, documentId: req.params.id, appendedChars: text.length });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/docs/:id/replace', async (req, res) => {
try {
const { docs: d, key } = resolveAccount(req);
if (!requireService(d, 'Docs', key, res)) return;
const { text = '' } = req.body || {};
// Wipe body then insert — delete range then insertText
const meta = await d.documents.get({ documentId: req.params.id });
const endIndex = meta.data.body?.content?.slice(-1)[0]?.endIndex || 1;
const requests = [];
if (endIndex > 2) {
requests.push({ deleteContentRange: { range: { startIndex: 1, endIndex: endIndex - 1 } } });
}
if (text) requests.push({ insertText: { location: { index: 1 }, text } });
if (requests.length) await d.documents.batchUpdate({ documentId: req.params.id, requestBody: { requests } });
audit(key, 'docs.replace', { documentId: req.params.id, length: text.length });
res.json({ account: key, documentId: req.params.id, length: text.length });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ─── Markdown → Google Docs formatter ───
// Parses a subset of Markdown (# headings, - bullets, > blockquotes, **bold**,
// `code`, [link](url), ---) into Google Docs batchUpdate requests.
function renderMarkdownToDocs(md) {
const lines = md.split('\n');
let text = '';
const paraRanges = [];
const textRanges = [];
let cursor = 1; // Docs indexing starts at 1
for (const raw of lines) {
let content = raw;
let namedStyleType = null;
let isBullet = false;
let isQuote = false;
if (raw.startsWith('### ')) { content = raw.slice(4); namedStyleType = 'HEADING_3'; }
else if (raw.startsWith('## ')) { content = raw.slice(3); namedStyleType = 'HEADING_2'; }
else if (raw.startsWith('# ')) { content = raw.slice(2); namedStyleType = 'HEADING_1'; }
else if (raw.startsWith('- ') || raw.startsWith('* ')) { content = raw.slice(2); isBullet = true; }
else if (raw.startsWith('> ')) { content = raw.slice(2); isQuote = true; }
else if (raw === '---' || raw === '***' || raw === '___') { content = ''; }
// Inline parse: **bold**, `code`, [text](url)
let stripped = '';
let i = 0;
while (i < content.length) {
if (content.slice(i, i + 2) === '**') {
const end = content.indexOf('**', i + 2);
if (end > 0) {
const inner = content.slice(i + 2, end);
textRanges.push({ start: cursor + stripped.length, end: cursor + stripped.length + inner.length, bold: true });
stripped += inner; i = end + 2; continue;
}
}
if (content[i] === '`') {
const end = content.indexOf('`', i + 1);
if (end > i) {
const inner = content.slice(i + 1, end);
textRanges.push({ start: cursor + stripped.length, end: cursor + stripped.length + inner.length, code: true });
stripped += inner; i = end + 1; continue;
}
}
if (content[i] === '[') {
const rb = content.indexOf(']', i + 1);
if (rb > 0 && content[rb + 1] === '(') {
const rp = content.indexOf(')', rb + 1);
if (rp > 0) {
const linkText = content.slice(i + 1, rb);
const url = content.slice(rb + 2, rp);
textRanges.push({ start: cursor + stripped.length, end: cursor + stripped.length + linkText.length, link: url });
stripped += linkText; i = rp + 1; continue;
}
}
}
stripped += content[i]; i++;
}
const lineStart = cursor;
text += stripped + '\n';
const lineEnd = cursor + stripped.length + 1;
if (namedStyleType) paraRanges.push({ start: lineStart, end: lineEnd, namedStyleType });
else if (isBullet) paraRanges.push({ start: lineStart, end: lineEnd, isBullet: true });
else if (isQuote) paraRanges.push({ start: lineStart, end: lineEnd, isQuote: true });
cursor = lineEnd;
}
// Build batchUpdate request set (order matters: insert first, then styles)
const requests = [];
requests.push({ insertText: { location: { index: 1 }, text } });
// Paragraph styles: headings + quotes
for (const p of paraRanges) {
if (p.namedStyleType) {
requests.push({
updateParagraphStyle: {
range: { startIndex: p.start, endIndex: p.end },
paragraphStyle: { namedStyleType: p.namedStyleType },
fields: 'namedStyleType'
}
});
} else if (p.isQuote) {
requests.push({
updateParagraphStyle: {
range: { startIndex: p.start, endIndex: p.end },
paragraphStyle: { indentStart: { magnitude: 36, unit: 'PT' }, namedStyleType: 'NORMAL_TEXT' },
fields: 'indentStart,namedStyleType'
}
});
if (p.end - 1 > p.start) {
requests.push({
updateTextStyle: {
range: { startIndex: p.start, endIndex: p.end - 1 },
textStyle: { italic: true, foregroundColor: { color: { rgbColor: { red: 0.3, green: 0.3, blue: 0.3 } } } },
fields: 'italic,foregroundColor'
}
});
}
}
}
// Bullets: group contiguous bullet paragraphs into one createParagraphBullets call
const bullets = paraRanges.filter(p => p.isBullet);
for (let i = 0; i < bullets.length;) {
let j = i;
while (j < bullets.length - 1 && bullets[j + 1].start === bullets[j].end) j++;
requests.push({
createParagraphBullets: {
range: { startIndex: bullets[i].start, endIndex: bullets[j].end },
bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE'
}
});
i = j + 1;
}
// Inline text styles: bold, code, links
for (const t of textRanges) {
if (t.end <= t.start) continue;
const textStyle = {};
const fields = [];
if (t.bold) { textStyle.bold = true; fields.push('bold'); }
if (t.code) {
textStyle.weightedFontFamily = { fontFamily: 'Roboto Mono', weight: 400 };
textStyle.backgroundColor = { color: { rgbColor: { red: 0.96, green: 0.96, blue: 0.96 } } };
fields.push('weightedFontFamily', 'backgroundColor');
}
if (t.link) {
textStyle.link = { url: t.link };
textStyle.foregroundColor = { color: { rgbColor: { red: 0.06, green: 0.43, blue: 0.56 } } };
textStyle.underline = true;
fields.push('link', 'foregroundColor', 'underline');
}
if (!fields.length) continue;
requests.push({
updateTextStyle: {
range: { startIndex: t.start, endIndex: t.end },
textStyle,
fields: fields.join(',')
}
});
}
return { text, requests };
}
app.post('/api/docs/:id/replace-markdown', async (req, res) => {
try {
const { docs: d, key } = resolveAccount(req);
if (!requireService(d, 'Docs', key, res)) return;
const { markdown = '' } = req.body || {};
// Step 1: wipe existing doc content
const meta = await d.documents.get({ documentId: req.params.id });
const endIndex = meta.data.body?.content?.slice(-1)[0]?.endIndex || 1;
if (endIndex > 2) {
await d.documents.batchUpdate({
documentId: req.params.id,
requestBody: { requests: [{ deleteContentRange: { range: { startIndex: 1, endIndex: endIndex - 1 } } }] }
});
}
// Step 2: render markdown → batchUpdate requests
const { text, requests } = renderMarkdownToDocs(markdown);
// Step 3: apply in chunks of 500 (Docs API limit is ~1000 per batch)
const CHUNK = 500;
let applied = 0;
for (let i = 0; i < requests.length; i += CHUNK) {
await d.documents.batchUpdate({
documentId: req.params.id,
requestBody: { requests: requests.slice(i, i + CHUNK) }
});
applied += Math.min(CHUNK, requests.length - i);
}
audit(key, 'docs.replace-markdown', { documentId: req.params.id, markdownLength: markdown.length, requests: requests.length });
res.json({
account: key,
documentId: req.params.id,
url: `https://docs.google.com/document/d/${req.params.id}/edit`,
markdownLength: markdown.length,
textLength: text.length,
requestsApplied: applied
});
} catch (e) {
res.status(500).json({ error: e.message, detail: (e.errors || []).slice(0, 3) });
}
});
// ─── Google Sheets ───
app.post('/api/sheets/create', async (req, res) => {
try {
const { sheets: s, key, label } = resolveAccount(req);
if (!requireService(s, 'Sheets', key, res)) return;
const { title = 'Untitled' } = req.body || {};
const result = await s.spreadsheets.create({ requestBody: { properties: { title } } });
audit(key, 'sheets.create', { spreadsheetId: result.data.spreadsheetId, title });
res.json({ account: key, email: label, spreadsheetId: result.data.spreadsheetId, title, url: result.data.spreadsheetUrl });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/sheets/:id', async (req, res) => {
try {
const { sheets: s, key } = resolveAccount(req);
if (!requireService(s, 'Sheets', key, res)) return;
const result = await s.spreadsheets.get({ spreadsheetId: req.params.id });
res.json({ account: key, spreadsheet: result.data });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/sheets/:id/values', async (req, res) => {
try {
const { sheets: s, key } = resolveAccount(req);
if (!requireService(s, 'Sheets', key, res)) return;
const range = req.query.range || 'Sheet1';
const result = await s.spreadsheets.values.get({ spreadsheetId: req.params.id, range });
res.json({ account: key, range, values: result.data.values || [] });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/sheets/:id/append', async (req, res) => {
try {
const { sheets: s, key } = resolveAccount(req);
if (!requireService(s, 'Sheets', key, res)) return;
const { range = 'Sheet1', values = [] } = req.body || {};
const result = await s.spreadsheets.values.append({
spreadsheetId: req.params.id, range, valueInputOption: 'USER_ENTERED',
requestBody: { values }
});
audit(key, 'sheets.append', { spreadsheetId: req.params.id, rows: values.length });
res.json({ account: key, updates: result.data.updates });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ─── Google Slides ───
app.post('/api/slides/create', async (req, res) => {
try {
const { slides: sl, key, label } = resolveAccount(req);
if (!requireService(sl, 'Slides', key, res)) return;
const { title = 'Untitled' } = req.body || {};
const result = await sl.presentations.create({ requestBody: { title } });
audit(key, 'slides.create', { presentationId: result.data.presentationId, title });
res.json({ account: key, email: label, presentationId: result.data.presentationId, title, url: `https://docs.google.com/presentation/d/${result.data.presentationId}/edit` });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/slides/:id', async (req, res) => {
try {
const { slides: sl, key } = resolveAccount(req);
if (!requireService(sl, 'Slides', key, res)) return;
const result = await sl.presentations.get({ presentationId: req.params.id });
res.json({ account: key, presentation: result.data });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ─── Google Drive (per-account) ───
app.get('/api/drive/list', async (req, res) => {
try {
const { drive: dr, key } = resolveAccount(req);
if (!requireService(dr, 'Drive', key, res)) return;
const result = await dr.files.list({
pageSize: parseInt(req.query.pageSize) || 25,
fields: 'files(id, name, mimeType, size, modifiedTime, webViewLink)',
orderBy: 'modifiedTime desc',
q: req.query.q || undefined,
});
res.json({ account: key, files: result.data.files || [] });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/drive/file/:id', async (req, res) => {
try {
const { drive: dr, key } = resolveAccount(req);
if (!requireService(dr, 'Drive', key, res)) return;
const result = await dr.files.get({
fileId: req.params.id,
fields: 'id, name, mimeType, size, modifiedTime, createdTime, webViewLink, parents, shared'
});
res.json({ account: key, file: result.data });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Download raw file bytes (alt=media). Streams directly to the response.
// Used by the AnimalsDirectory pet-photo importer to pull images without
// having to expose the user's OAuth token externally.
app.get('/api/drive/download/:id', async (req, res) => {
try {
const { drive: dr, key } = resolveAccount(req);
if (!requireService(dr, 'Drive', key, res)) return;
// First metadata call so we can set Content-Type + Content-Disposition correctly
const meta = await dr.files.get({ fileId: req.params.id, fields: 'name, mimeType, size' });
const stream = await dr.files.get(
{ fileId: req.params.id, alt: 'media' },
{ responseType: 'stream' }
);
res.setHeader('Content-Type', meta.data.mimeType || 'application/octet-stream');
if (meta.data.size) res.setHeader('Content-Length', meta.data.size);
res.setHeader('Content-Disposition', `inline; filename="${(meta.data.name || req.params.id).replace(/"/g, '')}"`);
stream.data.on('error', err => { audit(key, 'drive.download.error', { fileId: req.params.id, err: err.message }); });
stream.data.pipe(res);
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/drive/upload', async (req, res) => {
try {
const { drive: dr, key } = resolveAccount(req);
if (!requireService(dr, 'Drive', key, res)) return;
const { name, mimeType = 'text/plain', content = '', parents } = req.body || {};
if (!name) return res.status(400).json({ error: 'name required' });
const result = await dr.files.create({
requestBody: { name, parents },
media: { mimeType, body: content },
fields: 'id, name, mimeType, webViewLink'
});
audit(key, 'drive.upload', { fileId: result.data.id, name });
res.json({ account: key, file: result.data });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ─── Google Calendar (per-account) ───
app.get('/api/calendar/events', async (req, res) => {
try {
const { calendar: c, key } = resolveAccount(req);
if (!requireService(c, 'Calendar', key, res)) return;
const calendarId = req.query.calendarId || 'primary';
const timeMin = req.query.from || new Date().toISOString();
const timeMax = req.query.to;
const result = await c.events.list({
calendarId, timeMin, timeMax, singleEvents: true, orderBy: 'startTime',
maxResults: parseInt(req.query.maxResults) || 50
});
res.json({ account: key, calendarId, events: result.data.items || [] });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/calendar/events', async (req, res) => {
try {
const { calendar: c, key } = resolveAccount(req);
if (!requireService(c, 'Calendar', key, res)) return;
const { calendarId = 'primary', event } = req.body || {};
if (!event) return res.status(400).json({ error: 'event body required' });
const result = await c.events.insert({ calendarId, requestBody: event });
audit(key, 'calendar.event.create', { eventId: result.data.id, summary: event.summary });
res.json({ account: key, event: result.data });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ─── Health Check (updated for 3 accounts) ───
app.get('/health', (req, res) => {
res.json({
status: 'ok',
agent: 'George',
port: PORT,
uptime: process.uptime(),
scopes: FULL_WORKSPACE_SCOPES,
accounts: {
'steve-office': {
ready: !!gmail,
email: 'steve@designerwallcoverings.com',
services: { gmail: !!gmail, tasks: !!tasksApi, drive: !!drive, docs: !!docs, sheets: !!sheets, slides: !!slides, calendar: !!calendar, forms: !!forms },
authUrl: gmail ? null : '/auth/steve-office'
},
'info': {
ready: !!infoGmail,
email: 'info@designerwallcoverings.com',
services: { gmail: !!infoGmail, tasks: !!infoTasksApi, drive: !!infoDrive, docs: !!infoDocs, sheets: !!infoSheets, slides: !!infoSlides, calendar: !!infoCalendar, forms: !!infoForms },
authUrl: infoGmail ? null : '/auth/info'
},
'steve-personal': {
ready: !!personalGmail,
email: 'steveabramsdesigns@gmail.com',
services: { gmail: !!personalGmail, tasks: !!personalTasksApi, drive: !!personalDrive, docs: !!personalDocs, sheets: !!personalSheets, slides: !!personalSlides, calendar: !!personalCalendar, forms: !!personalForms },
authUrl: personalGmail ? null : '/auth/steve-personal'
}
}
});
});
// ─── Start ───
app.listen(PORT, () => {
console.log(`[${AGENT_NAME}] Gmail Agent running on port ${PORT}`);
console.log(`[${AGENT_NAME}] Dashboard: http://45.61.58.125:${PORT}/`);
console.log(`[${AGENT_NAME}] API: /api/messages, /api/search, /api/send, /api/labels, /api/profile`);
});