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,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);
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user