feat(watcher): add agent Gmail CLI with audit and defaults
Provide list/read/summarize plus reason-gated archive, label, draft, and forward-as-draft, with SQLite audit logging and default-mailbox resolution. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user