Files
nsrbandCursor f270f531ea feat(watcher): add agent Gmail CLI with audit and defaults
Provide list/read/summarize plus reason-gated archive, label, draft, and forward-as-draft, with SQLite audit logging and default-mailbox resolution.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 10:09:25 +07:00

788 lines
20 KiB
JavaScript

#!/usr/bin/env node
/**
* Unified Gmail CLI for agents (and humans).
*
* Usage:
* npm run gmail -- <command> [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 -- <command> [options]
Commands:
accounts [--default email] List connected mailboxes; set default
list [--query q] [--max n] List message id / from / subject
read <id> Read one message (headers + body)
summarize [--ids a,b] [--query q] [--max n]
Compact digests (agent writes the prose)
labels List mailbox labels
label <ids> --add L [--remove L] --reason "…"
archive <ids> --reason "…" Remove INBOX label
draft --to addr --subject s --body text --reason "…"
[--cc] [--in-reply-to] [--references]
forward <id> --to addr --reason "…" [--note text]
Create a forward draft (does not send)
audit [--action a] [--limit n] Recent CLI audit rows
Shared flags:
--user <email|id> 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);
});