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:
2026-09-22 10:09:25 +07:00
co-authored by Cursor
parent 770ed8121a
commit f270f531ea
13 changed files with 1643 additions and 145 deletions
+32 -1
View File
@@ -28,6 +28,15 @@ db.exec(`
);
`);
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 upsertUserStmt = db.prepare(`
INSERT INTO users (google_sub, email, name, picture)
VALUES (@googleSub, @email, @name, @picture)
@@ -49,9 +58,21 @@ const saveTokensStmt = db.prepare(`
const getUserByIdStmt = db.prepare(`SELECT * FROM users WHERE id = ?`);
const getTokensStmt = db.prepare(`SELECT * FROM oauth_tokens WHERE user_id = ?`);
const countDefaultsStmt = db.prepare(
`SELECT COUNT(*) AS n FROM users WHERE is_default = 1`,
);
const clearDefaultsStmt = db.prepare(`UPDATE users SET is_default = 0`);
const setDefaultStmt = db.prepare(
`UPDATE users SET is_default = 1 WHERE id = ?`,
);
export function upsertUser({ googleSub, email, name, picture }) {
return upsertUserStmt.get({ googleSub, email, name, picture });
const user = upsertUserStmt.get({ googleSub, email, name, picture });
if (user && countDefaultsStmt.get().n === 0) {
setDefaultStmt.run(user.id);
return getUserByIdStmt.get(user.id);
}
return user;
}
export function saveTokens(userId, { accessToken, refreshToken, expiry }) {
@@ -71,6 +92,15 @@ export function getTokens(userId) {
return getTokensStmt.get(userId) || null;
}
export function setDefaultUser(userId) {
const clearAndSet = db.transaction((id) => {
clearDefaultsStmt.run();
setDefaultStmt.run(id);
});
clearAndSet(userId);
return getUserByIdStmt.get(userId) || null;
}
export function publicUser(row) {
if (!row) return null;
return {
@@ -78,5 +108,6 @@ export function publicUser(row) {
email: row.email,
name: row.name,
picture: row.picture,
isDefault: Boolean(row.is_default),
};
}
+2 -1
View File
@@ -7,7 +7,8 @@
"start": "node src/index.js",
"test": "node --test test/**/*.test.js",
"process-messages": "node scripts/process-messages.js",
"list-messages": "node scripts/list-messages.js"
"list-messages": "node scripts/list-messages.js",
"gmail": "node scripts/gmail.js"
},
"dependencies": {
"better-sqlite3": "^12.2.0",
+787
View File
@@ -0,0 +1,787 @@
#!/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);
});
+16 -127
View File
@@ -1,140 +1,29 @@
#!/usr/bin/env node
/**
* List Gmail messages: id, from, subject (metadata only).
* Thin wrapper around: npm run gmail -- list …
*
* Usage:
* npm run list-messages -w watcher
* npm run list-messages -w watcher -- --query 'newer_than:14d has:attachment filename:eml'
* npm run list-messages -w watcher -- --max 100 --json
* npm run list-messages --
* npm run list-messages -- --query 'newer_than:14d' --max 100
* npm run list-messages -- --json
*
* Options:
* --query <q> Gmail search (default: newer_than:7d)
* --user <email> Mailbox (default: first user with tokens)
* --user <email> Mailbox (default: resolveUser order)
* --max <n> Max messages (default 50)
* --json Print JSON lines instead of a table
*/
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } from "../src/config.js";
import { getUserByEmail, listUsersWithTokens } from "../src/db.js";
import { gmailClientForUser } from "../src/google.js";
import { header } from "../src/mime.js";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const DEFAULT_QUERY = "newer_than:7d";
function parseArgs(argv) {
const out = {
query: null,
user: null,
max: 50,
json: false,
help: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--help" || a === "-h") out.help = true;
else if (a === "--json") out.json = true;
else if (a === "--query") out.query = argv[++i];
else if (a === "--user") out.user = argv[++i];
else if (a === "--max") out.max = Math.max(1, Number(argv[++i]) || 50);
else if (a.startsWith("--query=")) out.query = a.slice("--query=".length);
else if (a.startsWith("--user=")) out.user = a.slice("--user=".length);
else if (a.startsWith("--max=")) {
out.max = Math.max(1, Number(a.slice("--max=".length)) || 50);
}
}
return out;
}
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;
}
function metaFromPayload(payload) {
return {
from: header(payload, "From") || "",
subject: header(payload, "Subject") || "(no subject)",
date: header(payload, "Date") || "",
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(`Usage: list-messages [--query q] [--user email] [--max n] [--json]
Default query: ${DEFAULT_QUERY}`);
process.exit(0);
}
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
console.error("Missing GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET in .env");
process.exit(1);
}
const users = listUsersWithTokens();
if (!users.length) {
console.error("No signed-in users with tokens. Sign in via the web UI first.");
process.exit(1);
}
let user = users[0];
if (args.user) {
const byEmail = getUserByEmail(args.user);
const byId = users.find((u) => String(u.id) === String(args.user));
user = byEmail || byId || null;
if (!user) {
console.error(`User not found: ${args.user}`);
process.exit(1);
}
}
const gmail = await gmailClientForUser(user.id);
const query = args.query || DEFAULT_QUERY;
const ids = await listMessageIds(gmail, query, args.max);
if (!args.json) {
console.error(`# mailbox=${user.email} query=${JSON.stringify(query)} count=${ids.length}`);
}
for (const id of ids) {
const { data } = await gmail.users.messages.get({
userId: "me",
id,
format: "metadata",
metadataHeaders: ["From", "Subject", "Date"],
});
const meta = metaFromPayload(data.payload || {});
const row = {
id,
from: meta.from,
subject: meta.subject,
date: meta.date,
};
if (args.json) {
console.log(JSON.stringify(row));
} else {
console.log(`${row.id}\t${row.from}\t${row.subject}`);
}
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
const __dirname = dirname(fileURLToPath(import.meta.url));
const gmailJs = resolve(__dirname, "gmail.js");
const child = spawn(
process.execPath,
[gmailJs, "list", ...process.argv.slice(2)],
{ stdio: "inherit" },
);
child.on("exit", (code) => process.exit(code ?? 1));
+6 -16
View File
@@ -16,7 +16,7 @@
* Options:
* --query <q> Gmail search (default: in:inbox newer_than:30d)
* --ids <a,b> Explicit message IDs (skips list search)
* --user <email> Mailbox to use (default: first user with tokens)
* --user <email> Mailbox (default: resolveUser — --user / env / is_default / sole)
* --max <n> Max messages to process (default 500)
* --dry-run Parse and log only; do not write PocketBase
*/
@@ -26,8 +26,8 @@ import {
GOOGLE_CLIENT_SECRET,
POCKETBASE_URL,
} from "../src/config.js";
import { getUserByEmail, listUsersWithTokens } from "../src/db.js";
import { gmailClientForUser } from "../src/google.js";
import { resolveUser } from "../src/cli/resolve-user.js";
import { fetchAndLogMessage } from "../src/handlers/fetch-and-log-message.js";
import { matchRule } from "../src/handlers/match-rule.js";
import { parseMatchedMessage } from "../src/handlers/parse-matched-message.js";
@@ -128,22 +128,12 @@ Default query: ${DEFAULT_QUERY}`);
process.exit(1);
}
const users = listUsersWithTokens();
if (!users.length) {
console.error("No signed-in users with tokens. Sign in via the web UI first.");
const resolved = resolveUser(args.user);
if (!resolved.ok) {
console.error(resolved.error);
process.exit(1);
}
let user = users[0];
if (args.user) {
const byEmail = getUserByEmail(args.user);
const byId = users.find((u) => String(u.id) === String(args.user));
user = byEmail || byId || null;
if (!user) {
console.error(`User not found: ${args.user}`);
process.exit(1);
}
}
const user = resolved.user;
const log = makeLog();
const gmail = await gmailClientForUser(user.id);
+95
View File
@@ -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)];
}
+74
View File
@@ -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',
};
}
+101
View File
@@ -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)),
});
}
+102
View File
@@ -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;
}
+111
View File
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import Database from "better-sqlite3";
/**
* Exercise audit_log insert/list against an in-memory schema that mirrors
* apps/watcher/src/db.js (without opening the live app.db).
*/
function openAuditDb() {
const db = new Database(":memory:");
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
google_sub TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
name TEXT,
picture TEXT,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE 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
);
`);
return db;
}
describe("audit_log schema behavior", () => {
it("inserts and lists with reason + status", () => {
const db = openAuditDb();
const { lastInsertRowid: userId } = db
.prepare(
`INSERT INTO users (google_sub, email, is_default) VALUES ('sub1', 'a@example.com', 1)`,
)
.run();
const insert = db.prepare(`
INSERT INTO audit_log (
user_id, mailbox, actor, action, reason, message_ids, payload, status, error
) VALUES (?, ?, 'cli', ?, ?, ?, ?, ?, ?)
`);
insert.run(
userId,
"a@example.com",
"archive",
"newsletter noise",
JSON.stringify(["msg1"]),
JSON.stringify({ removeLabelIds: ["INBOX"] }),
"ok",
null,
);
insert.run(
userId,
"a@example.com",
"archive",
"preview",
JSON.stringify(["msg2"]),
JSON.stringify({ dryRun: true }),
"dry-run",
null,
);
const rows = db
.prepare(
`SELECT * FROM audit_log WHERE action = ? ORDER BY id DESC LIMIT 10`,
)
.all("archive");
assert.equal(rows.length, 2);
assert.equal(rows[0].status, "dry-run");
assert.equal(rows[1].reason, "newsletter noise");
assert.deepEqual(JSON.parse(rows[1].message_ids), ["msg1"]);
});
it("setDefault clears other defaults", () => {
const db = openAuditDb();
db.prepare(
`INSERT INTO users (google_sub, email, is_default) VALUES ('s1', 'a@example.com', 1)`,
).run();
db.prepare(
`INSERT INTO users (google_sub, email, is_default) VALUES ('s2', 'b@example.com', 0)`,
).run();
const clearAndSet = db.transaction((id) => {
db.prepare(`UPDATE users SET is_default = 0`).run();
db.prepare(`UPDATE users SET is_default = 1 WHERE id = ?`).run(id);
});
const b = db
.prepare(`SELECT id FROM users WHERE email = 'b@example.com'`)
.get();
clearAndSet(b.id);
const defaults = db
.prepare(`SELECT email FROM users WHERE is_default = 1`)
.all();
assert.deepEqual(
defaults.map((r) => r.email),
["b@example.com"],
);
});
});
+208
View File
@@ -0,0 +1,208 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { resolveUser } from "../src/cli/resolve-user.js";
import { parseArgv, parseIds, requireReason } from "../src/cli/parse-args.js";
import {
archiveModifyBody,
buildDraftRaw,
buildForwardDraftRaw,
labelModifyBody,
rawToGmailMessage,
} from "../src/gmail/draft.js";
function usersFixture() {
return [
{ id: 1, email: "a@example.com", is_default: 0 },
{ id: 2, email: "b@example.com", is_default: 1 },
];
}
describe("resolveUser", () => {
it("uses --user email", () => {
const users = usersFixture();
const result = resolveUser("a@example.com", {
listUsers: () => users,
getByEmail: (e) => users.find((u) => u.email === e) || null,
getById: (id) => users.find((u) => u.id === Number(id)) || null,
getDefault: () => users.find((u) => u.is_default) || null,
envDefault: "",
});
assert.equal(result.ok, true);
assert.equal(result.user.email, "a@example.com");
});
it("uses --user id", () => {
const users = usersFixture();
const result = resolveUser("2", {
listUsers: () => users,
getByEmail: () => null,
getById: (id) => users.find((u) => u.id === Number(id)) || null,
getDefault: () => null,
envDefault: "",
});
assert.equal(result.ok, true);
assert.equal(result.user.id, 2);
});
it("uses GMAIL_DEFAULT_USER over is_default", () => {
const users = usersFixture();
const result = resolveUser(null, {
listUsers: () => users,
getByEmail: (e) => users.find((u) => u.email === e) || null,
getById: () => null,
getDefault: () => users.find((u) => u.is_default) || null,
envDefault: "a@example.com",
});
assert.equal(result.ok, true);
assert.equal(result.user.email, "a@example.com");
});
it("uses is_default when no flag/env", () => {
const users = usersFixture();
const result = resolveUser(null, {
listUsers: () => users,
getByEmail: () => null,
getById: () => null,
getDefault: () => users.find((u) => u.is_default) || null,
envDefault: "",
});
assert.equal(result.ok, true);
assert.equal(result.user.email, "b@example.com");
});
it("uses sole connected user", () => {
const users = [{ id: 9, email: "only@example.com", is_default: 0 }];
const result = resolveUser(null, {
listUsers: () => users,
getByEmail: () => null,
getById: () => null,
getDefault: () => null,
envDefault: "",
});
assert.equal(result.ok, true);
assert.equal(result.user.email, "only@example.com");
});
it("fails when multiple and no default", () => {
const users = [
{ id: 1, email: "a@example.com", is_default: 0 },
{ id: 2, email: "b@example.com", is_default: 0 },
];
const result = resolveUser(null, {
listUsers: () => users,
getByEmail: () => null,
getById: () => null,
getDefault: () => null,
envDefault: "",
});
assert.equal(result.ok, false);
assert.match(result.error, /Multiple accounts/);
});
it("fails when no users", () => {
const result = resolveUser(null, {
listUsers: () => [],
getByEmail: () => null,
getById: () => null,
getDefault: () => null,
envDefault: "",
});
assert.equal(result.ok, false);
assert.match(result.error, /No signed-in users/);
});
});
describe("requireReason", () => {
it("rejects missing reason", () => {
const r = requireReason({ reason: " " }, "archive");
assert.equal(r.ok, false);
assert.match(r.error, /--reason is required/);
});
it("accepts non-empty reason", () => {
const r = requireReason({ reason: "noise" }, "archive");
assert.equal(r.ok, true);
assert.equal(r.reason, "noise");
});
});
describe("parseArgv / parseIds", () => {
it("parses dry-run and multi ids", () => {
const args = parseArgv(
["abc", "--dry-run", "--ids", "x,y", "--reason", "why"],
{
booleans: ["dryRun"],
strings: ["reason"],
multi: ["ids"],
},
);
assert.equal(args.dryRun, true);
assert.deepEqual(args.ids, ["x", "y"]);
assert.equal(args.reason, "why");
assert.deepEqual(parseIds(args), ["x", "y", "abc"]);
});
});
describe("draft MIME helpers", () => {
it("buildDraftRaw includes headers and body", () => {
const raw = buildDraftRaw({
to: "a@b.com",
subject: "Hello",
body: "Line1\nLine2",
cc: "c@d.com",
});
assert.match(raw, /^To: a@b.com\r\n/);
assert.match(raw, /Cc: c@d.com/);
assert.match(raw, /Subject: Hello/);
assert.match(raw, /Line1\nLine2$/);
});
it("buildForwardDraftRaw prefixes Fwd and quotes original", () => {
const raw = buildForwardDraftRaw({
to: "acct@example.com",
note: "Please book.",
original: {
from: "grab@example.com",
to: "me@example.com",
subject: "Your Grab E-Receipt",
date: "Mon, 1 Jan 2026",
body: "Total Rp10.000",
},
});
assert.match(raw, /Subject: Fwd: Your Grab E-Receipt/);
assert.match(raw, /Please book\./);
assert.match(raw, /---------- Forwarded message ---------/);
assert.match(raw, /From: grab@example.com/);
assert.match(raw, /Total Rp10\.000/);
});
it("does not double Fwd: prefix", () => {
const raw = buildForwardDraftRaw({
to: "a@b.com",
original: { subject: "Fwd: Already", body: "x" },
});
assert.match(raw, /Subject: Fwd: Already/);
assert.doesNotMatch(raw, /Subject: Fwd: Fwd:/);
});
it("rawToGmailMessage base64url-encodes", () => {
const { raw } = rawToGmailMessage("hi+/=?");
assert.equal(raw.includes("+"), false);
assert.equal(raw.includes("/"), false);
assert.ok(raw.length > 0);
});
it("archiveModifyBody removes INBOX only", () => {
assert.deepEqual(archiveModifyBody(), { removeLabelIds: ["INBOX"] });
});
it("labelModifyBody adds and removes", () => {
assert.deepEqual(labelModifyBody({ add: ["Label_1"], remove: ["INBOX"] }), {
addLabelIds: ["Label_1"],
removeLabelIds: ["INBOX"],
});
assert.deepEqual(labelModifyBody({ add: ["A"] }), {
addLabelIds: ["A"],
});
});
});