diff --git a/apps/api/src/db.js b/apps/api/src/db.js index 89cd74a..bfed6e3 100644 --- a/apps/api/src/db.js +++ b/apps/api/src/db.js @@ -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), }; } diff --git a/apps/watcher/package.json b/apps/watcher/package.json index 7e76146..b1d6a03 100644 --- a/apps/watcher/package.json +++ b/apps/watcher/package.json @@ -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", diff --git a/apps/watcher/scripts/gmail.js b/apps/watcher/scripts/gmail.js new file mode 100644 index 0000000..41e1c97 --- /dev/null +++ b/apps/watcher/scripts/gmail.js @@ -0,0 +1,787 @@ +#!/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); +}); diff --git a/apps/watcher/scripts/list-messages.js b/apps/watcher/scripts/list-messages.js index 75368fb..6c97d6c 100644 --- a/apps/watcher/scripts/list-messages.js +++ b/apps/watcher/scripts/list-messages.js @@ -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 Gmail search (default: newer_than:7d) - * --user Mailbox (default: first user with tokens) + * --user Mailbox (default: resolveUser order) * --max 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)); diff --git a/apps/watcher/scripts/process-messages.js b/apps/watcher/scripts/process-messages.js index ff61316..dcc8163 100644 --- a/apps/watcher/scripts/process-messages.js +++ b/apps/watcher/scripts/process-messages.js @@ -16,7 +16,7 @@ * Options: * --query Gmail search (default: in:inbox newer_than:30d) * --ids Explicit message IDs (skips list search) - * --user Mailbox to use (default: first user with tokens) + * --user Mailbox (default: resolveUser — --user / env / is_default / sole) * --max 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); diff --git a/apps/watcher/src/cli/parse-args.js b/apps/watcher/src/cli/parse-args.js new file mode 100644 index 0000000..2d257e5 --- /dev/null +++ b/apps/watcher/src/cli/parse-args.js @@ -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)]; +} diff --git a/apps/watcher/src/cli/resolve-user.js b/apps/watcher/src/cli/resolve-user.js new file mode 100644 index 0000000..369d46b --- /dev/null +++ b/apps/watcher/src/cli/resolve-user.js @@ -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', + }; +} diff --git a/apps/watcher/src/db.js b/apps/watcher/src/db.js index 7e06544..05532da 100644 --- a/apps/watcher/src/db.js +++ b/apps/watcher/src/db.js @@ -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)), + }); +} diff --git a/apps/watcher/src/gmail/draft.js b/apps/watcher/src/gmail/draft.js new file mode 100644 index 0000000..00b914f --- /dev/null +++ b/apps/watcher/src/gmail/draft.js @@ -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; +} diff --git a/apps/watcher/test/audit.test.js b/apps/watcher/test/audit.test.js new file mode 100644 index 0000000..185134a --- /dev/null +++ b/apps/watcher/test/audit.test.js @@ -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"], + ); + }); +}); diff --git a/apps/watcher/test/cli-gmail.test.js b/apps/watcher/test/cli-gmail.test.js new file mode 100644 index 0000000..ab20525 --- /dev/null +++ b/apps/watcher/test/cli-gmail.test.js @@ -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"], + }); + }); +}); diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..3f9bd26 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,108 @@ +# Usage — agent Gmail toolbox + +How Cursor agents (and you) operate connected Gmail accounts via CLI. The web UI is a reader only. + +## Prerequisites + +1. Sign in via the web UI at least once **per** mailbox (`gmail.modify` scope — see [development.md](development.md#change-google-api-permissions-gmailmodify)). +2. Set a default if more than one account is connected: + +```bash +npm run gmail -- accounts +npm run gmail -- accounts --default you@gmail.com +``` + +Optional env override: `GMAIL_DEFAULT_USER=you@gmail.com`. + +## Account resolution + +1. `--user ` +2. `GMAIL_DEFAULT_USER` +3. `users.is_default` +4. Sole connected account +5. Else error — set a default + +## Commands + +```bash +npm run gmail -- … +``` + +| Command | Purpose | `--reason` | +|---|---|---| +| `accounts` | List mailboxes / set `--default` | no | +| `list` | Search → id / from / subject | no | +| `read ` | Full headers + body | no | +| `summarize` | Compact digests for one or many (`--ids` or `--query`) | no | +| `labels` | List label ids/names | no | +| `label --add/--remove` | Modify labels | **required** | +| `archive ` | Remove `INBOX` | **required** | +| `draft --to --subject --body` | Create a draft (**never sends**) | **required** | +| `forward --to [--note]` | Forward-as-draft | **required** | +| `audit` | Show recent CLI audit rows | no | + +Shared: `--user`, `--json`, `--dry-run` (mutations: no Gmail write; audit `status=dry-run`). + +Aliases: + +```bash +npm run list-messages -- … # → gmail list +npm run process-messages -- … # receipt backfill (see development.md) +``` + +### Examples + +```bash +npm run gmail -- list --query 'is:unread newer_than:3d' --max 40 +npm run gmail -- read 18c0… --json +npm run gmail -- summarize --query 'in:inbox newer_than:7d' --max 15 + +npm run gmail -- label ABC,DEF --add receipts --reason "tag Grab receipts" +npm run gmail -- archive ABC --reason "newsletter: Product Hunt daily" +npm run gmail -- archive ABC --dry-run --reason "preview bulk archive" + +npm run gmail -- draft \ + --to colleague@example.com \ + --subject "Re: Invoice" \ + --body "Thanks — I'll review today." \ + --reason "polite reply draft; user will send" + +npm run gmail -- forward ABC \ + --to accounting@example.com \ + --note "Please book this receipt." \ + --reason "forward Grab receipt to accounting" + +npm run gmail -- audit --limit 30 +npm run gmail -- audit --action archive --user you@gmail.com --json +``` + +## Audit log + +Every CLI action writes a row to SQLite `audit_log` (same `apps/api/data/app.db`): + +- `actor` = `cli` +- `action`, `mailbox`, `message_ids`, `payload` (JSON), `status` (`ok` | `error` | `dry-run`) +- `reason` — required for mutations; optional elsewhere + +Failures still write `status=error`. Receipt watcher success stays in PocketBase `spendings.parsed` + stdout; it is not mixed into this table. + +## Safety + +- Never call send. Never trash/delete. +- Always pass a real `--reason` on mutations (explains intent in the audit log). +- Ask the user before bulk archive (roughly >20 messages). +- Prefer `--dry-run` when unsure. +- Drafts stay in Gmail Drafts until the user sends them in the Gmail UI. + +## Ideas — what agents can do + +These are workflows on top of the CLI, not extra product features. + +- **Inbox triage:** list unread → summarize a batch → archive newsletters after labeling. +- **Receipt desk:** search provider subjects → `--add receipts` → `process-messages` into PocketBase. +- **Draft, never send:** reply drafts, declines, “please re-send invoice” — you hit Send in Gmail. +- **Forward-as-draft:** receipt or travel mail → draft to accounting/partner with `--note`. +- **Label taxonomy:** `travel`, `bills`, `need-reply`, `waiting` — agents apply labels instead of inventing folders. +- **Weekly digest:** `newer_than:7d is:unread` → summarize by sender → archive obvious noise. +- **Follow-up queue:** find unanswered threads → draft a bump → leave in Drafts. +- **Multi-mailbox:** omit `--user` for the default; pass `--user` for work vs personal. diff --git a/package.json b/package.json index 9a5515b..2ddfc7d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:watcher": "npm run test -w watcher", "process-messages": "npm run process-messages -w watcher --", "list-messages": "npm run list-messages -w watcher --", + "gmail": "npm run gmail -w watcher --", "build": "npm run build -w web", "start": "npm run start -w api", "start:watcher": "npm run start -w watcher"