#!/usr/bin/env node /** * Unified Gmail CLI for agents (and humans). * * Usage: * npm run gmail -- [options] * * Commands: accounts | list | read | summarize | labels | label | archive | draft | forward | audit * * Mutating commands (label, archive, draft, forward) require --reason. * Never sends mail. Never trashes/deletes. */ import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, } from "../src/config.js"; import { getUserByEmail, listAuditLog, listUsersWithTokens, setDefaultUser, writeAuditLog, } from "../src/db.js"; import { gmailClientForUser } from "../src/google.js"; import { extractBody, header, summarizeMessage } from "../src/mime.js"; import { parseArgv, parseIds, requireReason } from "../src/cli/parse-args.js"; import { resolveUser } from "../src/cli/resolve-user.js"; import { archiveModifyBody, buildDraftRaw, buildForwardDraftRaw, labelModifyBody, rawToGmailMessage, } from "../src/gmail/draft.js"; const MUTATING = new Set(["label", "archive", "draft", "forward"]); function printHelp() { console.log(`Usage: npm run gmail -- [options] Commands: accounts [--default email] List connected mailboxes; set default list [--query q] [--max n] List message id / from / subject read Read one message (headers + body) summarize [--ids a,b] [--query q] [--max n] Compact digests (agent writes the prose) labels List mailbox labels label --add L [--remove L] --reason "…" archive --reason "…" Remove INBOX label draft --to addr --subject s --body text --reason "…" [--cc] [--in-reply-to] [--references] forward --to addr --reason "…" [--note text] Create a forward draft (does not send) audit [--action a] [--limit n] Recent CLI audit rows Shared flags: --user Mailbox (else default / env / sole account) --json JSON output where applicable --dry-run Mutating: print plan, no Gmail write (audit status=dry-run) --reason "…" Required for label | archive | draft | forward Safety: never send, never trash/delete. Confirm with the user before bulk archive.`); } function ensureGoogleCreds() { if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) { console.error("Missing GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET in .env"); process.exit(1); } } function resolveOrExit(userFlag) { const result = resolveUser(userFlag); if (!result.ok) { console.error(result.error); process.exit(1); } return result.user; } async function listMessageIds(gmail, query, max) { const ids = []; let pageToken; while (ids.length < max) { const { data } = await gmail.users.messages.list({ userId: "me", q: query, maxResults: Math.min(50, max - ids.length), pageToken, }); for (const m of data.messages || []) { if (m.id) ids.push(m.id); if (ids.length >= max) break; } pageToken = data.nextPageToken; if (!pageToken) break; } return ids; } async function fetchMeta(gmail, id) { const { data } = await gmail.users.messages.get({ userId: "me", id, format: "metadata", metadataHeaders: ["From", "Subject", "Date", "To"], }); const payload = data.payload || {}; return { id, from: header(payload, "From") || "", to: header(payload, "To") || "", subject: header(payload, "Subject") || "(no subject)", date: header(payload, "Date") || "", labelIds: data.labelIds || [], }; } async function fetchFull(gmail, id) { const { data } = await gmail.users.messages.get({ userId: "me", id, format: "full", }); const summary = summarizeMessage(data); return { ...summary, labelIds: data.labelIds || [], body: extractBody(data.payload || {}), messageIdHeader: header(data.payload, "Message-ID") || "", }; } function audit(entry) { try { return writeAuditLog(entry); } catch (err) { console.error("audit write failed:", err.message || err); return null; } } async function cmdAccounts(args) { if (args.default) { const user = getUserByEmail(args.default) || listUsersWithTokens().find((u) => String(u.id) === String(args.default)); if (!user) { console.error(`User not found: ${args.default}`); process.exit(1); } const tokens = listUsersWithTokens().find((u) => u.id === user.id); if (!tokens) { console.error(`User has no OAuth tokens: ${user.email}`); process.exit(1); } setDefaultUser(user.id); console.log(`Default mailbox set to ${user.email}`); audit({ userId: user.id, mailbox: user.email, action: "accounts", reason: args.reason || null, payload: { default: user.email }, status: "ok", }); return; } const users = listUsersWithTokens(); if (!users.length) { console.error("No signed-in users with tokens. Sign in via the web UI first."); process.exit(1); } const rows = users.map((u) => ({ id: u.id, email: u.email, name: u.name, default: Boolean(u.is_default), })); if (args.json) { for (const row of rows) console.log(JSON.stringify(row)); } else { for (const row of rows) { const mark = row.default ? "*" : " "; console.log(`${mark} ${row.id}\t${row.email}\t${row.name || ""}`); } console.error("# * = default mailbox"); } audit({ userId: null, mailbox: "(all)", action: "accounts", payload: { count: rows.length }, status: "ok", }); } async function cmdList(args) { const user = resolveOrExit(args.user); const gmail = await gmailClientForUser(user.id); const query = args.query || "newer_than:7d"; const max = Math.max(1, Number(args.max) || 50); const ids = await listMessageIds(gmail, query, max); const rows = []; for (const id of ids) { rows.push(await fetchMeta(gmail, id)); } if (!args.json) { console.error( `# mailbox=${user.email} query=${JSON.stringify(query)} count=${rows.length}`, ); } for (const row of rows) { if (args.json) console.log(JSON.stringify(row)); else console.log(`${row.id}\t${row.from}\t${row.subject}`); } audit({ userId: user.id, mailbox: user.email, action: "list", messageIds: ids, payload: { query, max, count: ids.length }, status: "ok", }); } async function cmdRead(args) { const user = resolveOrExit(args.user); const ids = parseIds(args); if (ids.length !== 1) { console.error("read requires exactly one message id"); process.exit(1); } const gmail = await gmailClientForUser(user.id); const msg = await fetchFull(gmail, ids[0]); if (args.json) { console.log(JSON.stringify(msg)); } else { console.log(`id: ${msg.id}`); console.log(`from: ${msg.from}`); console.log(`to: ${msg.to}`); console.log(`subject: ${msg.subject}`); console.log(`date: ${msg.date}`); console.log(`labels: ${(msg.labelIds || []).join(", ")}`); console.log(""); console.log(msg.body); } audit({ userId: user.id, mailbox: user.email, action: "read", messageIds: [msg.id], payload: { subject: msg.subject }, status: "ok", }); } async function cmdSummarize(args) { const user = resolveOrExit(args.user); const gmail = await gmailClientForUser(user.id); let ids = parseIds(args); if (!ids.length) { const query = args.query || "in:inbox newer_than:7d"; const max = Math.max(1, Number(args.max) || 20); ids = await listMessageIds(gmail, query, max); } else { ids = ids.slice(0, Math.max(1, Number(args.max) || ids.length)); } const digests = []; for (const id of ids) { const msg = await fetchFull(gmail, id); digests.push({ id: msg.id, from: msg.from, to: msg.to, subject: msg.subject, date: msg.date, labels: msg.labelIds || [], body: msg.body, }); } if (args.json) { for (const d of digests) console.log(JSON.stringify(d)); } else { for (const d of digests) { console.log("---"); console.log(`id: ${d.id}`); console.log(`from: ${d.from}`); console.log(`to: ${d.to}`); console.log(`subject: ${d.subject}`); console.log(`date: ${d.date}`); console.log(`labels: ${d.labels.join(", ")}`); console.log(""); console.log(d.body); console.log(""); } } audit({ userId: user.id, mailbox: user.email, action: "summarize", messageIds: ids, payload: { count: digests.length, query: args.query || null }, status: "ok", }); } async function cmdLabels(args) { const user = resolveOrExit(args.user); const gmail = await gmailClientForUser(user.id); const { data } = await gmail.users.labels.list({ userId: "me" }); const labels = (data.labels || []).map((l) => ({ id: l.id, name: l.name, type: l.type, })); if (args.json) { for (const l of labels) console.log(JSON.stringify(l)); } else { for (const l of labels) { console.log(`${l.id}\t${l.name}\t${l.type || ""}`); } } audit({ userId: user.id, mailbox: user.email, action: "labels", payload: { count: labels.length }, status: "ok", }); } async function cmdLabel(args) { const reasonCheck = requireReason(args, "label"); if (!reasonCheck.ok) { console.error(reasonCheck.error); process.exit(1); } const user = resolveOrExit(args.user); const ids = parseIds(args); const add = args.add || []; const remove = args.remove || []; if (!ids.length) { console.error("label requires at least one message id"); process.exit(1); } if (!add.length && !remove.length) { console.error("label requires --add and/or --remove"); process.exit(1); } const body = labelModifyBody({ add, remove }); if (args.dryRun) { console.log( JSON.stringify({ dryRun: true, mailbox: user.email, ids, ...body }, null, 2), ); audit({ userId: user.id, mailbox: user.email, action: "label", reason: reasonCheck.reason, messageIds: ids, payload: { ...body, dryRun: true }, status: "dry-run", }); return; } const gmail = await gmailClientForUser(user.id); try { for (const id of ids) { await gmail.users.messages.modify({ userId: "me", id, requestBody: body, }); } console.log(`Labeled ${ids.length} message(s)`); audit({ userId: user.id, mailbox: user.email, action: "label", reason: reasonCheck.reason, messageIds: ids, payload: body, status: "ok", }); } catch (err) { audit({ userId: user.id, mailbox: user.email, action: "label", reason: reasonCheck.reason, messageIds: ids, payload: body, status: "error", error: err.message || String(err), }); throw err; } } async function cmdArchive(args) { const reasonCheck = requireReason(args, "archive"); if (!reasonCheck.ok) { console.error(reasonCheck.error); process.exit(1); } const user = resolveOrExit(args.user); const ids = parseIds(args); if (!ids.length) { console.error("archive requires at least one message id"); process.exit(1); } const body = archiveModifyBody(); if (args.dryRun) { console.log( JSON.stringify({ dryRun: true, mailbox: user.email, ids, ...body }, null, 2), ); audit({ userId: user.id, mailbox: user.email, action: "archive", reason: reasonCheck.reason, messageIds: ids, payload: { ...body, dryRun: true }, status: "dry-run", }); return; } const gmail = await gmailClientForUser(user.id); try { for (const id of ids) { await gmail.users.messages.modify({ userId: "me", id, requestBody: body, }); } console.log(`Archived ${ids.length} message(s)`); audit({ userId: user.id, mailbox: user.email, action: "archive", reason: reasonCheck.reason, messageIds: ids, payload: body, status: "ok", }); } catch (err) { audit({ userId: user.id, mailbox: user.email, action: "archive", reason: reasonCheck.reason, messageIds: ids, payload: body, status: "error", error: err.message || String(err), }); throw err; } } async function cmdDraft(args) { const reasonCheck = requireReason(args, "draft"); if (!reasonCheck.ok) { console.error(reasonCheck.error); process.exit(1); } if (!args.to || !args.subject) { console.error("draft requires --to and --subject"); process.exit(1); } const user = resolveOrExit(args.user); const bodyText = args.body || ""; const raw = buildDraftRaw({ to: args.to, subject: args.subject, body: bodyText, cc: args.cc || undefined, inReplyTo: args.inReplyTo || undefined, references: args.references || undefined, }); const message = rawToGmailMessage(raw); if (args.dryRun) { console.log( JSON.stringify( { dryRun: true, mailbox: user.email, to: args.to, subject: args.subject, body: bodyText, cc: args.cc || null, }, null, 2, ), ); audit({ userId: user.id, mailbox: user.email, action: "draft", reason: reasonCheck.reason, payload: { to: args.to, subject: args.subject, dryRun: true, }, status: "dry-run", }); return; } const gmail = await gmailClientForUser(user.id); try { const { data } = await gmail.users.drafts.create({ userId: "me", requestBody: { message }, }); console.log( JSON.stringify({ draftId: data.id, messageId: data.message?.id || null, to: args.to, subject: args.subject, }), ); audit({ userId: user.id, mailbox: user.email, action: "draft", reason: reasonCheck.reason, messageIds: data.message?.id ? [data.message.id] : [], payload: { draftId: data.id, to: args.to, subject: args.subject, }, status: "ok", }); } catch (err) { audit({ userId: user.id, mailbox: user.email, action: "draft", reason: reasonCheck.reason, payload: { to: args.to, subject: args.subject }, status: "error", error: err.message || String(err), }); throw err; } } async function cmdForward(args) { const reasonCheck = requireReason(args, "forward"); if (!reasonCheck.ok) { console.error(reasonCheck.error); process.exit(1); } if (!args.to) { console.error("forward requires --to"); process.exit(1); } const ids = parseIds(args); if (ids.length !== 1) { console.error("forward requires exactly one message id"); process.exit(1); } const user = resolveOrExit(args.user); const gmail = await gmailClientForUser(user.id); const original = await fetchFull(gmail, ids[0]); const raw = buildForwardDraftRaw({ to: args.to, original: { from: original.from, to: original.to, subject: original.subject, date: original.date, body: original.body, }, note: args.note || undefined, cc: args.cc || undefined, }); const message = rawToGmailMessage(raw); const subject = original.subject?.toLowerCase().startsWith("fwd:") ? original.subject : `Fwd: ${original.subject}`; if (args.dryRun) { console.log( JSON.stringify( { dryRun: true, mailbox: user.email, sourceId: original.id, to: args.to, subject, note: args.note || null, }, null, 2, ), ); audit({ userId: user.id, mailbox: user.email, action: "forward", reason: reasonCheck.reason, messageIds: [original.id], payload: { to: args.to, subject, dryRun: true }, status: "dry-run", }); return; } try { const { data } = await gmail.users.drafts.create({ userId: "me", requestBody: { message }, }); console.log( JSON.stringify({ draftId: data.id, messageId: data.message?.id || null, sourceId: original.id, to: args.to, subject, }), ); audit({ userId: user.id, mailbox: user.email, action: "forward", reason: reasonCheck.reason, messageIds: [original.id], payload: { draftId: data.id, to: args.to, subject, }, status: "ok", }); } catch (err) { audit({ userId: user.id, mailbox: user.email, action: "forward", reason: reasonCheck.reason, messageIds: [original.id], payload: { to: args.to, subject }, status: "error", error: err.message || String(err), }); throw err; } } async function cmdAudit(args) { let userId = null; let mailboxFilter = null; if (args.user) { const user = resolveOrExit(args.user); userId = user.id; mailboxFilter = user.email; } const rows = listAuditLog({ userId, action: args.action || null, limit: args.limit || 50, }); if (args.json) { for (const row of rows) { console.log( JSON.stringify({ ...row, message_ids: row.message_ids ? JSON.parse(row.message_ids) : null, payload: row.payload ? JSON.parse(row.payload) : null, }), ); } } else { if (mailboxFilter) { console.error(`# mailbox=${mailboxFilter} count=${rows.length}`); } for (const row of rows) { const reason = row.reason ? ` reason=${JSON.stringify(row.reason)}` : ""; console.log( `${row.id}\t${row.created_at}\t${row.mailbox}\t${row.action}\t${row.status}${reason}`, ); } } } async function main() { const argv = process.argv.slice(2); const command = argv[0]; if (!command || command === "--help" || command === "-h") { printHelp(); process.exit(command ? 0 : 1); } const rest = argv.slice(1); const args = parseArgv(rest, { booleans: ["json", "dryRun"], strings: [ "user", "query", "max", "reason", "to", "subject", "body", "cc", "note", "default", "action", "limit", "inReplyTo", "references", ], multi: ["ids", "add", "remove"], }); // Accept --dry-run as dryRun via camelCase from parseArgv... --dry-run → dryRun // parseArgv converts dry-run to dryRun via camel — good. if (args.help) { printHelp(); process.exit(0); } if (MUTATING.has(command) && !(args.reason || "").trim()) { console.error(`--reason is required for ${command}`); process.exit(1); } if ( ["list", "read", "summarize", "labels", "label", "archive", "draft", "forward"].includes( command, ) ) { ensureGoogleCreds(); } switch (command) { case "accounts": await cmdAccounts(args); break; case "list": await cmdList(args); break; case "read": await cmdRead(args); break; case "summarize": await cmdSummarize(args); break; case "labels": await cmdLabels(args); break; case "label": await cmdLabel(args); break; case "archive": await cmdArchive(args); break; case "draft": await cmdDraft(args); break; case "forward": await cmdForward(args); break; case "audit": await cmdAudit(args); break; default: console.error(`Unknown command: ${command}`); printHelp(); process.exit(1); } } main().catch((err) => { console.error(err.message || err); process.exit(1); });