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>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Minimal shared argv parser for gmail CLI commands.
|
||||
* Supports --flag value, --flag=value, and boolean --flags.
|
||||
*/
|
||||
export function parseArgv(argv, { booleans = [], strings = [], multi = [] } = {}) {
|
||||
const out = {
|
||||
_: [],
|
||||
help: false,
|
||||
};
|
||||
for (const key of booleans) out[key] = false;
|
||||
for (const key of strings) out[key] = null;
|
||||
for (const key of multi) out[key] = [];
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--help" || a === "-h") {
|
||||
out.help = true;
|
||||
continue;
|
||||
}
|
||||
if (!a.startsWith("--")) {
|
||||
out._.push(a);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = a.indexOf("=");
|
||||
let name;
|
||||
let rawValue;
|
||||
if (eq >= 0) {
|
||||
name = a.slice(2, eq);
|
||||
rawValue = a.slice(eq + 1);
|
||||
} else {
|
||||
name = a.slice(2);
|
||||
rawValue = undefined;
|
||||
}
|
||||
|
||||
const camel = name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
|
||||
if (booleans.includes(camel) || booleans.includes(name)) {
|
||||
const key = booleans.includes(camel) ? camel : name;
|
||||
out[key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (multi.includes(camel) || multi.includes(name)) {
|
||||
const key = multi.includes(camel) ? camel : name;
|
||||
const value = rawValue !== undefined ? rawValue : argv[++i];
|
||||
if (value == null) continue;
|
||||
out[key].push(
|
||||
...String(value)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strings.includes(camel) || strings.includes(name)) {
|
||||
const key = strings.includes(camel) ? camel : name;
|
||||
out[key] = rawValue !== undefined ? rawValue : argv[++i];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown flag: treat as string if next token looks like a value
|
||||
if (rawValue !== undefined) {
|
||||
out[camel] = rawValue;
|
||||
} else if (argv[i + 1] && !argv[i + 1].startsWith("--")) {
|
||||
out[camel] = argv[++i];
|
||||
} else {
|
||||
out[camel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function requireReason(args, action) {
|
||||
const reason = (args.reason || "").trim();
|
||||
if (!reason) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `--reason is required for ${action}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, reason };
|
||||
}
|
||||
|
||||
export function parseIds(args) {
|
||||
const fromFlag = Array.isArray(args.ids) ? args.ids : [];
|
||||
const fromPositional = args._ || [];
|
||||
const joined = [...fromFlag, ...fromPositional]
|
||||
.flatMap((s) => String(s).split(","))
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return [...new Set(joined)];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { GMAIL_DEFAULT_USER } from "../config.js";
|
||||
import {
|
||||
getDefaultUser,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
listUsersWithTokens,
|
||||
} from "../db.js";
|
||||
|
||||
/**
|
||||
* Resolve which connected mailbox a CLI command should use.
|
||||
*
|
||||
* Order: --user → GMAIL_DEFAULT_USER env → users.is_default → sole connected user.
|
||||
*
|
||||
* @param {string|null|undefined} userFlag
|
||||
* @param {object} [deps] injectable for tests
|
||||
* @returns {{ ok: true, user: object } | { ok: false, error: string }}
|
||||
*/
|
||||
export function resolveUser(userFlag, deps = {}) {
|
||||
const listUsers = deps.listUsers || listUsersWithTokens;
|
||||
const getByEmail = deps.getByEmail || getUserByEmail;
|
||||
const getById = deps.getById || getUserById;
|
||||
const getDefault = deps.getDefault || getDefaultUser;
|
||||
const envDefault =
|
||||
deps.envDefault !== undefined ? deps.envDefault : GMAIL_DEFAULT_USER;
|
||||
|
||||
const users = listUsers();
|
||||
if (!users.length) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"No signed-in users with tokens. Sign in via the web UI first.",
|
||||
};
|
||||
}
|
||||
|
||||
const hasTokens = (user) => user && users.some((u) => u.id === user.id);
|
||||
|
||||
if (userFlag) {
|
||||
const byEmail = getByEmail(userFlag);
|
||||
const byId =
|
||||
users.find((u) => String(u.id) === String(userFlag)) ||
|
||||
getById(userFlag);
|
||||
const user = byEmail || byId || null;
|
||||
if (!user || !hasTokens(user)) {
|
||||
return { ok: false, error: `User not found: ${userFlag}` };
|
||||
}
|
||||
return { ok: true, user };
|
||||
}
|
||||
|
||||
if (envDefault) {
|
||||
const fromEnv = getByEmail(envDefault);
|
||||
if (!fromEnv || !hasTokens(fromEnv)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `GMAIL_DEFAULT_USER not found or has no tokens: ${envDefault}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, user: fromEnv };
|
||||
}
|
||||
|
||||
const flagged = getDefault();
|
||||
if (flagged && hasTokens(flagged)) {
|
||||
return { ok: true, user: flagged };
|
||||
}
|
||||
|
||||
if (users.length === 1) {
|
||||
return { ok: true, user: users[0] };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'Multiple accounts connected; set a default: npm run gmail -- accounts --default you@gmail.com',
|
||||
};
|
||||
}
|
||||
@@ -32,8 +32,31 @@ db.exec(`
|
||||
history_id TEXT NOT NULL,
|
||||
expiration INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
mailbox TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
message_ids TEXT,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
function ensureColumn(table, column, definition) {
|
||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||
if (!cols.some((c) => c.name === column)) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
ensureColumn("users", "is_default", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
const getUserByIdStmt = db.prepare(`SELECT * FROM users WHERE id = ?`);
|
||||
const getUserByEmailStmt = db.prepare(
|
||||
`SELECT * FROM users WHERE LOWER(email) = LOWER(?)`,
|
||||
@@ -43,7 +66,19 @@ const listUsersWithTokensStmt = db.prepare(`
|
||||
SELECT u.*
|
||||
FROM users u
|
||||
INNER JOIN oauth_tokens t ON t.user_id = u.id
|
||||
ORDER BY u.is_default DESC, u.id ASC
|
||||
`);
|
||||
const getDefaultUserStmt = db.prepare(`
|
||||
SELECT u.*
|
||||
FROM users u
|
||||
INNER JOIN oauth_tokens t ON t.user_id = u.id
|
||||
WHERE u.is_default = 1
|
||||
LIMIT 1
|
||||
`);
|
||||
const clearDefaultsStmt = db.prepare(`UPDATE users SET is_default = 0`);
|
||||
const setDefaultStmt = db.prepare(
|
||||
`UPDATE users SET is_default = 1 WHERE id = ?`,
|
||||
);
|
||||
const saveTokensStmt = db.prepare(`
|
||||
INSERT INTO oauth_tokens (user_id, access_token, refresh_token, expiry)
|
||||
VALUES (@userId, @accessToken, @refreshToken, @expiry)
|
||||
@@ -63,6 +98,22 @@ const saveWatchStateStmt = db.prepare(`
|
||||
expiration = excluded.expiration
|
||||
`);
|
||||
|
||||
const insertAuditStmt = db.prepare(`
|
||||
INSERT INTO audit_log (
|
||||
user_id, mailbox, actor, action, reason, message_ids, payload, status, error
|
||||
) VALUES (
|
||||
@userId, @mailbox, @actor, @action, @reason, @messageIds, @payload, @status, @error
|
||||
)
|
||||
`);
|
||||
|
||||
const listAuditStmt = db.prepare(`
|
||||
SELECT * FROM audit_log
|
||||
WHERE (@userId IS NULL OR user_id = @userId)
|
||||
AND (@action IS NULL OR action = @action)
|
||||
ORDER BY id DESC
|
||||
LIMIT @limit
|
||||
`);
|
||||
|
||||
export function getUserById(id) {
|
||||
return getUserByIdStmt.get(id) || null;
|
||||
}
|
||||
@@ -80,6 +131,19 @@ export function listUsersWithTokens() {
|
||||
return listUsersWithTokensStmt.all();
|
||||
}
|
||||
|
||||
export function getDefaultUser() {
|
||||
return getDefaultUserStmt.get() || null;
|
||||
}
|
||||
|
||||
export function setDefaultUser(userId) {
|
||||
const clearAndSet = db.transaction((id) => {
|
||||
clearDefaultsStmt.run();
|
||||
setDefaultStmt.run(id);
|
||||
});
|
||||
clearAndSet(userId);
|
||||
return getUserByIdStmt.get(userId) || null;
|
||||
}
|
||||
|
||||
export function saveTokens(userId, { accessToken, refreshToken, expiry }) {
|
||||
saveTokensStmt.run({
|
||||
userId,
|
||||
@@ -100,3 +164,40 @@ export function saveWatchState(userId, { historyId, expiration }) {
|
||||
expiration: Number(expiration),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} entry
|
||||
* @param {number|null} [entry.userId]
|
||||
* @param {string} entry.mailbox
|
||||
* @param {string} [entry.actor]
|
||||
* @param {string} entry.action
|
||||
* @param {string|null} [entry.reason]
|
||||
* @param {string[]} [entry.messageIds]
|
||||
* @param {object|null} [entry.payload]
|
||||
* @param {string} entry.status
|
||||
* @param {string|null} [entry.error]
|
||||
*/
|
||||
export function writeAuditLog(entry) {
|
||||
const result = insertAuditStmt.run({
|
||||
userId: entry.userId ?? null,
|
||||
mailbox: entry.mailbox || "",
|
||||
actor: entry.actor || "cli",
|
||||
action: entry.action,
|
||||
reason: entry.reason ?? null,
|
||||
messageIds: entry.messageIds
|
||||
? JSON.stringify(entry.messageIds)
|
||||
: null,
|
||||
payload: entry.payload != null ? JSON.stringify(entry.payload) : null,
|
||||
status: entry.status,
|
||||
error: entry.error ?? null,
|
||||
});
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function listAuditLog({ userId = null, action = null, limit = 50 } = {}) {
|
||||
return listAuditStmt.all({
|
||||
userId: userId == null ? null : Number(userId),
|
||||
action: action || null,
|
||||
limit: Math.max(1, Math.min(500, Number(limit) || 50)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Build RFC822 raw message bodies for drafts (create only — never send).
|
||||
*/
|
||||
|
||||
function encodeSubject(subject) {
|
||||
// ASCII-safe; UTF-8 subjects use encoded-word if non-ascii
|
||||
if (!/[^\x20-\x7E]/.test(subject || "")) return subject || "";
|
||||
const b64 = Buffer.from(subject, "utf8").toString("base64");
|
||||
return `=?UTF-8?B?${b64}?=`;
|
||||
}
|
||||
|
||||
function encodeBase64Url(raw) {
|
||||
return Buffer.from(raw, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* to: string,
|
||||
* subject: string,
|
||||
* body: string,
|
||||
* cc?: string,
|
||||
* inReplyTo?: string,
|
||||
* references?: string,
|
||||
* }} opts
|
||||
*/
|
||||
export function buildDraftRaw({
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
cc,
|
||||
inReplyTo,
|
||||
references,
|
||||
}) {
|
||||
const lines = [];
|
||||
lines.push(`To: ${to}`);
|
||||
if (cc) lines.push(`Cc: ${cc}`);
|
||||
lines.push(`Subject: ${encodeSubject(subject)}`);
|
||||
if (inReplyTo) lines.push(`In-Reply-To: ${inReplyTo}`);
|
||||
if (references) lines.push(`References: ${references}`);
|
||||
lines.push("MIME-Version: 1.0");
|
||||
lines.push('Content-Type: text/plain; charset="UTF-8"');
|
||||
lines.push("Content-Transfer-Encoding: 8bit");
|
||||
lines.push("");
|
||||
lines.push(body || "");
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* to: string,
|
||||
* original: { from?: string, to?: string, subject?: string, date?: string, body?: string },
|
||||
* note?: string,
|
||||
* cc?: string,
|
||||
* }} opts
|
||||
*/
|
||||
export function buildForwardDraftRaw({ to, original, note, cc }) {
|
||||
const origSubject = original.subject || "(no subject)";
|
||||
const subject = origSubject.toLowerCase().startsWith("fwd:")
|
||||
? origSubject
|
||||
: `Fwd: ${origSubject}`;
|
||||
|
||||
const parts = [];
|
||||
if (note) {
|
||||
parts.push(note.trim());
|
||||
parts.push("");
|
||||
}
|
||||
parts.push("---------- Forwarded message ---------");
|
||||
if (original.from) parts.push(`From: ${original.from}`);
|
||||
if (original.date) parts.push(`Date: ${original.date}`);
|
||||
parts.push(`Subject: ${origSubject}`);
|
||||
if (original.to) parts.push(`To: ${original.to}`);
|
||||
parts.push("");
|
||||
parts.push(original.body || "");
|
||||
|
||||
return buildDraftRaw({
|
||||
to,
|
||||
subject,
|
||||
body: parts.join("\n"),
|
||||
cc,
|
||||
});
|
||||
}
|
||||
|
||||
export function rawToGmailMessage(raw) {
|
||||
return { raw: encodeBase64Url(raw) };
|
||||
}
|
||||
|
||||
/** Request body for archive: remove INBOX label. */
|
||||
export function archiveModifyBody() {
|
||||
return { removeLabelIds: ["INBOX"] };
|
||||
}
|
||||
|
||||
/** Request body for label add/remove. */
|
||||
export function labelModifyBody({ add = [], remove = [] } = {}) {
|
||||
const body = {};
|
||||
if (add.length) body.addLabelIds = add;
|
||||
if (remove.length) body.removeLabelIds = remove;
|
||||
return body;
|
||||
}
|
||||
Reference in New Issue
Block a user