Compare commits

..
5 Commits
Author SHA1 Message Date
nsrbandCursor ec3b683786 docs: refresh agent playbooks and project README
Point agents at the CLI safety rules, mailbox defaults, and the split usage/development guides.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 10:09:25 +07:00
nsrbandCursor 427636b59b feat(watcher): add Flip transfer receipt provider
Match no-reply@flip.id transfer success mail (direct or forwarded), parse stacked Nominal/ID Transaksi fields, and map spendings with source=flip.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 10:09:25 +07:00
nsrbandCursor f270f531ea 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>
2026-09-22 10:09:25 +07:00
nsrbandCursor 770ed8121a feat(watcher): gate receipt watch with WATCHER_MAILBOXES
Limit Pub/Sub INBOX watch and receipt processing to an allowlisted mailbox set; leave the agent CLI usable for all connected accounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 10:09:25 +07:00
nsrbandCursor bac8edfb6c feat(api): switch OAuth scope to gmail.modify
Enable archive, label, and draft CLI operations after users re-consent; keep the web UI read-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 10:09:25 +07:00
31 changed files with 2214 additions and 240 deletions
+7 -1
View File
@@ -1,4 +1,4 @@
# Google OAuth Web client (gmail.readonly) # Google OAuth Web client (gmail.modify)
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
@@ -14,8 +14,14 @@ SESSION_SECRET=change-me-to-a-long-random-string
PORT=3000 PORT=3000
NODE_ENV=development NODE_ENV=development
# Optional default mailbox when CLI omits --user (else users.is_default / sole account)
# GMAIL_DEFAULT_USER=you@gmail.com
# Watcher (apps/watcher) — Gmail Pub/Sub push # Watcher (apps/watcher) — Gmail Pub/Sub push
WATCHER_PORT=3001 WATCHER_PORT=3001
# Only these mailboxes get INBOX watch + receipt parsing (comma-separated).
# Empty / unset = all connected accounts. CLI (npm run gmail) is unaffected.
WATCHER_MAILBOXES=eleven16th@gmail.com
# Full topic name, e.g. projects/my-gcp-project/topics/gmail-push # Full topic name, e.g. projects/my-gcp-project/topics/gmail-push
# A short name like gmail-push is expanded if GOOGLE_CLOUD_PROJECT is set. # A short name like gmail-push is expanded if GOOGLE_CLOUD_PROJECT is set.
GOOGLE_PUBSUB_TOPIC= GOOGLE_PUBSUB_TOPIC=
+43 -79
View File
@@ -1,104 +1,68 @@
# Agent notes — gmail-hook # Agent notes — gmail-hook
npm workspaces: `apps/api`, `apps/web`, `apps/watcher`. Goal is **e-receipt email → parsed spending → PocketBase**, not a generic Gmail client. npm workspaces: `apps/api`, `apps/web`, `apps/watcher`.
Two jobs:
1. **E-receipt pipeline** — Gmail Pub/Sub → match/parse → PocketBase `spendings`.
2. **Agent Gmail toolbox** — CLI to list/read/summarize, archive, label, and create drafts (never send).
## Layout ## Layout
| App | Role | | App | Role |
|---|---| |---|---|
| `apps/api` | Google OAuth, session cookie, Gmail read API. Tokens in SQLite `apps/api/data/app.db`. | | `apps/api` | Google OAuth (`gmail.modify`), session cookie, read-only Gmail HTTP API. Tokens in SQLite `apps/api/data/app.db`. |
| `apps/web` | React login / inbox UI (Vite). | | `apps/web` | React login / inbox UI (Vite). UI is read-only; mutations are CLI-only. |
| `apps/watcher` | Gmail Pub/Sub push consumer. Matches receipts, parses JSON, writes `spendings`. | | `apps/watcher` | Pub/Sub receipt consumer (gated by `WATCHER_MAILBOXES`) + `npm run gmail` CLI. Reuses the same SQLite tokens. |
Shared MIME helpers live in each app (`mime.js`). Watcher reuses the same SQLite tokens as the API. ## Safety (CLI)
## Watcher pipeline - **Never send** (`messages.send` / `drafts.send` must not exist in this repo).
- **Never trash/delete**.
- Mutating commands (`label`, `archive`, `draft`, `forward`) **require `--reason`**.
- Confirm with the user before bulk archive (e.g. more than ~20 ids).
- Google’s consent screen for `gmail.modify` says the app can send/delete; that is Google’s wording — the app still must not.
`POST /pubsub/gmail` → `history.list` (`messageAdded`) → per message: ## Default mailbox
1. `logEvent` + `fetchAndLogMessage` — fetch outer mail; collect `emlMessages` if any Resolution order for every script (`--user` optional):
2. **Work items:** if `.eml` / `message/rfc822` attachments exist → process **each attachment only** (outer is a wrapper). If none → process the outer message as usual.
3. For each work item (sequential, in-process — no job queue): `matchRule` → `parseMatchedMessage` → `saveSpending`
Handlers return `{ pass: {...} }` to merge into ctx or `{ break: true }` to stop. **Parse full `message.body`** (and `message.html` when needed), never the 500-char log `bodyPreview`. 1. `--user <email|id>`
2. `GMAIL_DEFAULT_USER` env
3. `users.is_default = 1`
4. Sole connected account
5. Else fail — set default: `npm run gmail -- accounts --default you@gmail.com`
Attached `.eml` mails use nested From/Subject from Gmail’s expanded `message/rfc822` part. When nested HTML/text is not inlined (`body.data` missing, only `attachmentId`), the watcher fetches those attachments (`hydrateRfc822Message`) before parse. Synthetic `message_id` looks like `outerId#partId`; dedupe remains on `invoice_id`. ## CLI cheat sheet
## Match rules ```bash
npm run gmail -- accounts
npm run gmail -- accounts --default you@gmail.com
npm run gmail -- list --query 'is:unread newer_than:7d' --max 50
npm run gmail -- read MESSAGE_ID
npm run gmail -- summarize --query 'in:inbox newer_than:7d' --max 20
npm run gmail -- labels
npm run gmail -- label ID1,ID2 --add receipts --reason "tag receipts"
npm run gmail -- archive ID1,ID2 --reason "newsletter noise"
npm run gmail -- draft --to a@b.com --subject "Hi" --body "…" --reason "reply draft"
npm run gmail -- forward MESSAGE_ID --to a@b.com --note "FYI" --reason "forward to accounting"
npm run gmail -- audit --limit 50
npm run process-messages -- --query 'newer_than:2d' --dry-run
```
Resolve original sender: Shared flags: `--user`, `--json`, `--dry-run` (mutations: no Gmail write; audit `status=dry-run`).
1. If body has `---------- Forwarded message ---------`, use its From / Subject / Date. ## Docs
2. Else use Gmail envelope.
| kind | From address | Subject | - [docs/usage.md](docs/usage.md) — how agents use Gmail (commands, audit, recipes, ideas).
|---|---|---| - [docs/development.md](docs/development.md) — receipt pipeline, parsers, PocketBase, Google scope change, adding providers.
| `livin.qr_payment.success` | `noreply.livin@bankmandiri.co.id` | `Pembayaran Berhasil!` |
| `grab.ereceipt.ride` | `no-reply@grab.com` | `Your Grab E-Receipt` |
| `grab.ereceipt.tip` | same From/Subject as ride; detected from tip body copy | tip invoice_id = `{Booking ID}:tip` |
| `grab.ereceipt.food` | same From/Subject; body has GrabFood / `Selamat menikmati makanan` | `Pesanan ID` as invoice_id |
| `bri.transfer.success` | `bankbri@bri.co.id` | `Pemindahan Dana Sesama Rekening BRI` |
| `bri.qris.success` | `bankbri@bri.co.id` | `Pembelian QRIS Berhasil` |
| `tokopedia.order.completed` | `noreply@tokopedia.com` | `Pesanan Selesai` |
Production path is **direct provider mail**. Forwards still work via the Fwd header. Do **not** match on the forwarder envelope From.
Rules: `apps/watcher/src/rules/`. Parsers: `apps/watcher/src/parsers/`.
## Parsing notes
- **Required for a spending row:** `amount` + `reference` (invoice id). Fail soft (log + break) if missing — never throw (Pub/Sub must ack).
- **Livin:** support clean forwarded lines and jammed HTML-stripped labels (`Tanggal13 Sep 2026`, `Tanggal15 Agu 2026`). Indonesian month names/abbreviations (`Agu`/`Agustus`, `Des`, …) are accepted. When merchant and city are glued on one line, keep the whole string as `merchant`; only split location when it is already a separate line ending in `- ID`. Do not maintain a city-name list.
- **Grab:** plain text often has date-only `Picked up on …`; pickup/dropoff times (`10:47AM`) live in HTML. Watcher keeps `message.html` and merges the first standalone AM/PM time into `occurredAt` (WIB). Tip receipts (`Your tip goes a long way…`, timestamp like `13 Sep 26 10:47 +0700`) become `grab.ereceipt.tip` with `invoice_id` `{bookingId}:tip` so they do not collide with the ride.
- **BRI / BRImo:** transfer (`Pemindahan Dana Sesama Rekening BRI`) — amount from `Nominal`, `invoice_id` from `Nomor Referensi`, `description` from `Nama Tujuan`, datetime from `Tanggal`. QRIS (`Pembelian QRIS Berhasil`) — amount from `Nominal` (fallback `Total Transaksi`), `description` from `Nama Merchant`, datetime from `Tanggal Transaksi`. Other BRI subjects stay out of scope until samples appear.
- **Tokopedia:** `Pesanan Selesai` — one row per `No.Invoice`; amount from `Total belanja` (not fee lines); `description` = `{Toko} - Tokopedia`; line items (1+) in `details.items`; `trx_date` from `Tanggal Terima` (date-only).
- Open-ended text (names, products, driver, compliments) is best-effort optional `details`.
## PocketBase
Env: `POCKETBASE_URL`, `POCKETBASE_USER`, `POCKETBASE_PASSWORD` (a `_superusers` account). Collection: `spendings`.
- Unique `invoice_id` — duplicate insert → log skip, no throw.
- Leave `category` empty; user fills it in PocketBase admin.
- Store flat columns for querying; `details` for kind leftovers; `parsed` for the full watcher envelope JSON (audit snapshot). Keep `message_id` so the original Gmail message can be re-fetched if needed — do not store raw email body by default.
- Schema ensure runs on watcher boot (`ensureSpendingsSchema`).
- Never commit secrets.
Parse failures and non-duplicate skips (incomplete record, save errors) POST to ntfy (`NTFY_URL`, default `https://n.0dev.web.id/system`) with `messageId` and message body. Duplicates do not notify.
## Run ## Run
```bash ```bash
npm install npm install
npm run dev # api + web npm run dev # api + web
# sign in once via UI so SQLite has tokens # sign in once per Gmail account (re-consent after scope change)
npm run dev:watcher npm run dev:watcher
npm test -w watcher # unit tests (no Gmail/Pub/Sub) npm test -w watcher
``` ```
### Manual / backfill (forwarded old receipts)
Gmail Date on a forward is “today”; `trx_date` still comes from the receipt body. Duplicates skip on unique `invoice_id`. Forward-as-attachment (multiple `*.eml`) is expanded the same way as the watcher.
```bash
# List id / from / subject (from Gmail API)
npm run list-messages --
npm run list-messages -- --query 'newer_than:14d has:attachment filename:eml' --max 100
# Dry-run last 30 days of inbox (default query: in:inbox newer_than:30d)
npm run process-messages -- --dry-run
# Process a wrapper mail that has .eml attachments
npm run process-messages -- --ids OUTER_MESSAGE_ID
# Process and save
npm run process-messages -- --query 'newer_than:2d subject:"Pembayaran Berhasil!"'
npm run process-messages -- --ids MESSAGE_ID_1,MESSAGE_ID_2
npm run process-messages -- --user eleven16th@gmail.com --max 100
```
## Adding a provider
1. Rule module under `src/rules/` (kind, fromAddress, subject, source).
2. Parser under `src/parsers/`; register in `parse-matched-message.js`.
3. Fixture + tests under `test/`.
4. Map into spendings in `pocketbase/map.js` (`source`, `description`, `details`, `parsed`).
+33 -10
View File
@@ -1,17 +1,31 @@
# Gmail Reader # Gmail Reader
Monorepo with a React + Vite + DaisyUI client, a Fastify API, and a Gmail push watcher. The API owns Google OAuth, stores tokens in SQLite, and calls the Gmail API with `gmail.readonly`. The browser only receives an HTTP-only session cookie. The watcher reuses those tokens, calls `users.watch`, and runs each new INBOX message through a handler pipeline. Monorepo with a React + Vite + DaisyUI client, a Fastify API, and a Gmail push watcher. The API owns Google OAuth, stores tokens in SQLite, and calls the Gmail API with `gmail.modify`. The browser only receives an HTTP-only session cookie (read-only inbox UI). The watcher reuses those tokens for Pub/Sub receipt processing and for the agent CLI (`npm run gmail`).
Agent playbooks: [AGENTS.md](AGENTS.md), [docs/usage.md](docs/usage.md), [docs/development.md](docs/development.md).
## Prerequisites ## Prerequisites
- Node.js 20+ - Node.js 20+
- A Google Cloud OAuth **Web application** client with: - A Google Cloud OAuth **Web application** client with:
- Scope: `https://www.googleapis.com/auth/gmail.readonly` - Scope: `https://www.googleapis.com/auth/gmail.modify`
- Authorized JavaScript origin: `https://oauth.0dev.web.id` (and `http://localhost:5173` for local) - Authorized JavaScript origin: `https://oauth.0dev.web.id` (and `http://localhost:5173` for local)
- Authorized redirect URIs: - Authorized redirect URIs:
- `https://oauth.0dev.web.id/callback` - `https://oauth.0dev.web.id/callback`
- `http://localhost:5173/callback` (local; Vite proxies this to Fastify) - `http://localhost:5173/callback` (local; Vite proxies this to Fastify)
### Change Google API permissions
If you previously used `gmail.readonly`, update the OAuth consent screen and re-consent:
1. **APIs & Services → Library**: Gmail API enabled.
2. **OAuth consent screen** (or **Google Auth Platform → Data Access**): add `https://www.googleapis.com/auth/gmail.modify`; remove `gmail.readonly` if listed.
3. While the app is **External + Testing**, list every mailbox under **Test users** (`gmail.modify` is sensitive).
4. Keep the same Client ID / secret and redirect URIs.
5. Restart the API and **sign in once per Gmail account** via the web UI so refresh tokens pick up the new scope.
Full detail: [docs/development.md](docs/development.md#change-google-api-permissions-gmailmodify).
## Local development ## Local development
```bash ```bash
@@ -37,7 +51,17 @@ npm run dev
Vite proxies `/auth`, `/callback`, and `/api` to Fastify so the Google redirect stays on the same origin as the SPA. Vite proxies `/auth`, `/callback`, and `/api` to Fastify so the Google redirect stays on the same origin as the SPA.
Sign in via the UI at least once so SQLite has a refresh token before starting the watcher. Sign in via the UI at least once so SQLite has a refresh token before starting the watcher or CLI.
### Agent CLI (optional)
```bash
npm run gmail -- accounts
npm run gmail -- list --query 'newer_than:7d'
npm run gmail -- archive MESSAGE_ID --reason "noise"
```
See [docs/usage.md](docs/usage.md). Mutating commands require `--reason`. The CLI never sends mail.
## Production (one process) ## Production (one process)
@@ -92,13 +116,12 @@ oauth.0dev.web.id {
} }
``` ```
The watcher renews `users.watch` on startup and every 12 hours. Handlers run in sequence per new message: log the event, then fetch and log the message body. A handler may pass fields to the next one or `break` the chain. The watcher renews `users.watch` on startup and every 12 hours. New INBOX mail runs through match → parse → PocketBase (`spendings`). Details: [docs/development.md](docs/development.md).
## What it does ## What it does
- Sign in with Google (server-side authorization code flow) - Sign in with Google (server-side authorization code flow; `gmail.modify`)
- List latest mail - List / search / read mail in the web UI (read-only)
- Search with Gmail query syntax (`from:`, `subject:`, `newer_than:`, …) - Multi-account tokens in SQLite with a configurable default mailbox
- Open a message and read decoded plain-text content - Agent CLI: list, read, summarize, labels, archive, label, draft, forward-as-draft, audit log
- Watch INBOX via Gmail push (Pub/Sub) and run a sequential handler pipeline (log event, then fetch and log the message) - Watch INBOX via Gmail push (Pub/Sub) and parse e-receipts into PocketBase
- Watch INBOX via Gmail push (Pub/Sub) and run a sequential handler pipeline (log event, then fetch and log the message)
+1 -1
View File
@@ -17,7 +17,7 @@ export const GOOGLE_REDIRECT_URI =
process.env.GOOGLE_REDIRECT_URI || "http://localhost:5173/callback"; process.env.GOOGLE_REDIRECT_URI || "http://localhost:5173/callback";
export const SESSION_SECRET = process.env.SESSION_SECRET || ""; export const SESSION_SECRET = process.env.SESSION_SECRET || "";
export const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; export const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.modify";
export const OAUTH_SCOPES = [ export const OAUTH_SCOPES = [
GMAIL_SCOPE, GMAIL_SCOPE,
"openid", "openid",
+32 -1
View File
@@ -28,6 +28,15 @@ db.exec(`
); );
`); `);
function ensureColumn(table, column, definition) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
if (!cols.some((c) => c.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}
ensureColumn("users", "is_default", "INTEGER NOT NULL DEFAULT 0");
const upsertUserStmt = db.prepare(` const upsertUserStmt = db.prepare(`
INSERT INTO users (google_sub, email, name, picture) INSERT INTO users (google_sub, email, name, picture)
VALUES (@googleSub, @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 getUserByIdStmt = db.prepare(`SELECT * FROM users WHERE id = ?`);
const getTokensStmt = db.prepare(`SELECT * FROM oauth_tokens WHERE user_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 }) { 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 }) { export function saveTokens(userId, { accessToken, refreshToken, expiry }) {
@@ -71,6 +92,15 @@ export function getTokens(userId) {
return getTokensStmt.get(userId) || null; 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) { export function publicUser(row) {
if (!row) return null; if (!row) return null;
return { return {
@@ -78,5 +108,6 @@ export function publicUser(row) {
email: row.email, email: row.email,
name: row.name, name: row.name,
picture: row.picture, picture: row.picture,
isDefault: Boolean(row.is_default),
}; };
} }
+2 -1
View File
@@ -7,7 +7,8 @@
"start": "node src/index.js", "start": "node src/index.js",
"test": "node --test test/**/*.test.js", "test": "node --test test/**/*.test.js",
"process-messages": "node scripts/process-messages.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": { "dependencies": {
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
+787
View File
@@ -0,0 +1,787 @@
#!/usr/bin/env node
/**
* Unified Gmail CLI for agents (and humans).
*
* Usage:
* npm run gmail -- <command> [options]
*
* Commands: accounts | list | read | summarize | labels | label | archive | draft | forward | audit
*
* Mutating commands (label, archive, draft, forward) require --reason.
* Never sends mail. Never trashes/deletes.
*/
import {
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
} from "../src/config.js";
import {
getUserByEmail,
listAuditLog,
listUsersWithTokens,
setDefaultUser,
writeAuditLog,
} from "../src/db.js";
import { gmailClientForUser } from "../src/google.js";
import { extractBody, header, summarizeMessage } from "../src/mime.js";
import { parseArgv, parseIds, requireReason } from "../src/cli/parse-args.js";
import { resolveUser } from "../src/cli/resolve-user.js";
import {
archiveModifyBody,
buildDraftRaw,
buildForwardDraftRaw,
labelModifyBody,
rawToGmailMessage,
} from "../src/gmail/draft.js";
const MUTATING = new Set(["label", "archive", "draft", "forward"]);
function printHelp() {
console.log(`Usage: npm run gmail -- <command> [options]
Commands:
accounts [--default email] List connected mailboxes; set default
list [--query q] [--max n] List message id / from / subject
read <id> Read one message (headers + body)
summarize [--ids a,b] [--query q] [--max n]
Compact digests (agent writes the prose)
labels List mailbox labels
label <ids> --add L [--remove L] --reason "…"
archive <ids> --reason "…" Remove INBOX label
draft --to addr --subject s --body text --reason "…"
[--cc] [--in-reply-to] [--references]
forward <id> --to addr --reason "…" [--note text]
Create a forward draft (does not send)
audit [--action a] [--limit n] Recent CLI audit rows
Shared flags:
--user <email|id> Mailbox (else default / env / sole account)
--json JSON output where applicable
--dry-run Mutating: print plan, no Gmail write (audit status=dry-run)
--reason "…" Required for label | archive | draft | forward
Safety: never send, never trash/delete. Confirm with the user before bulk archive.`);
}
function ensureGoogleCreds() {
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
console.error("Missing GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET in .env");
process.exit(1);
}
}
function resolveOrExit(userFlag) {
const result = resolveUser(userFlag);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
return result.user;
}
async function listMessageIds(gmail, query, max) {
const ids = [];
let pageToken;
while (ids.length < max) {
const { data } = await gmail.users.messages.list({
userId: "me",
q: query,
maxResults: Math.min(50, max - ids.length),
pageToken,
});
for (const m of data.messages || []) {
if (m.id) ids.push(m.id);
if (ids.length >= max) break;
}
pageToken = data.nextPageToken;
if (!pageToken) break;
}
return ids;
}
async function fetchMeta(gmail, id) {
const { data } = await gmail.users.messages.get({
userId: "me",
id,
format: "metadata",
metadataHeaders: ["From", "Subject", "Date", "To"],
});
const payload = data.payload || {};
return {
id,
from: header(payload, "From") || "",
to: header(payload, "To") || "",
subject: header(payload, "Subject") || "(no subject)",
date: header(payload, "Date") || "",
labelIds: data.labelIds || [],
};
}
async function fetchFull(gmail, id) {
const { data } = await gmail.users.messages.get({
userId: "me",
id,
format: "full",
});
const summary = summarizeMessage(data);
return {
...summary,
labelIds: data.labelIds || [],
body: extractBody(data.payload || {}),
messageIdHeader: header(data.payload, "Message-ID") || "",
};
}
function audit(entry) {
try {
return writeAuditLog(entry);
} catch (err) {
console.error("audit write failed:", err.message || err);
return null;
}
}
async function cmdAccounts(args) {
if (args.default) {
const user = getUserByEmail(args.default) ||
listUsersWithTokens().find((u) => String(u.id) === String(args.default));
if (!user) {
console.error(`User not found: ${args.default}`);
process.exit(1);
}
const tokens = listUsersWithTokens().find((u) => u.id === user.id);
if (!tokens) {
console.error(`User has no OAuth tokens: ${user.email}`);
process.exit(1);
}
setDefaultUser(user.id);
console.log(`Default mailbox set to ${user.email}`);
audit({
userId: user.id,
mailbox: user.email,
action: "accounts",
reason: args.reason || null,
payload: { default: user.email },
status: "ok",
});
return;
}
const users = listUsersWithTokens();
if (!users.length) {
console.error("No signed-in users with tokens. Sign in via the web UI first.");
process.exit(1);
}
const rows = users.map((u) => ({
id: u.id,
email: u.email,
name: u.name,
default: Boolean(u.is_default),
}));
if (args.json) {
for (const row of rows) console.log(JSON.stringify(row));
} else {
for (const row of rows) {
const mark = row.default ? "*" : " ";
console.log(`${mark} ${row.id}\t${row.email}\t${row.name || ""}`);
}
console.error("# * = default mailbox");
}
audit({
userId: null,
mailbox: "(all)",
action: "accounts",
payload: { count: rows.length },
status: "ok",
});
}
async function cmdList(args) {
const user = resolveOrExit(args.user);
const gmail = await gmailClientForUser(user.id);
const query = args.query || "newer_than:7d";
const max = Math.max(1, Number(args.max) || 50);
const ids = await listMessageIds(gmail, query, max);
const rows = [];
for (const id of ids) {
rows.push(await fetchMeta(gmail, id));
}
if (!args.json) {
console.error(
`# mailbox=${user.email} query=${JSON.stringify(query)} count=${rows.length}`,
);
}
for (const row of rows) {
if (args.json) console.log(JSON.stringify(row));
else console.log(`${row.id}\t${row.from}\t${row.subject}`);
}
audit({
userId: user.id,
mailbox: user.email,
action: "list",
messageIds: ids,
payload: { query, max, count: ids.length },
status: "ok",
});
}
async function cmdRead(args) {
const user = resolveOrExit(args.user);
const ids = parseIds(args);
if (ids.length !== 1) {
console.error("read requires exactly one message id");
process.exit(1);
}
const gmail = await gmailClientForUser(user.id);
const msg = await fetchFull(gmail, ids[0]);
if (args.json) {
console.log(JSON.stringify(msg));
} else {
console.log(`id: ${msg.id}`);
console.log(`from: ${msg.from}`);
console.log(`to: ${msg.to}`);
console.log(`subject: ${msg.subject}`);
console.log(`date: ${msg.date}`);
console.log(`labels: ${(msg.labelIds || []).join(", ")}`);
console.log("");
console.log(msg.body);
}
audit({
userId: user.id,
mailbox: user.email,
action: "read",
messageIds: [msg.id],
payload: { subject: msg.subject },
status: "ok",
});
}
async function cmdSummarize(args) {
const user = resolveOrExit(args.user);
const gmail = await gmailClientForUser(user.id);
let ids = parseIds(args);
if (!ids.length) {
const query = args.query || "in:inbox newer_than:7d";
const max = Math.max(1, Number(args.max) || 20);
ids = await listMessageIds(gmail, query, max);
} else {
ids = ids.slice(0, Math.max(1, Number(args.max) || ids.length));
}
const digests = [];
for (const id of ids) {
const msg = await fetchFull(gmail, id);
digests.push({
id: msg.id,
from: msg.from,
to: msg.to,
subject: msg.subject,
date: msg.date,
labels: msg.labelIds || [],
body: msg.body,
});
}
if (args.json) {
for (const d of digests) console.log(JSON.stringify(d));
} else {
for (const d of digests) {
console.log("---");
console.log(`id: ${d.id}`);
console.log(`from: ${d.from}`);
console.log(`to: ${d.to}`);
console.log(`subject: ${d.subject}`);
console.log(`date: ${d.date}`);
console.log(`labels: ${d.labels.join(", ")}`);
console.log("");
console.log(d.body);
console.log("");
}
}
audit({
userId: user.id,
mailbox: user.email,
action: "summarize",
messageIds: ids,
payload: { count: digests.length, query: args.query || null },
status: "ok",
});
}
async function cmdLabels(args) {
const user = resolveOrExit(args.user);
const gmail = await gmailClientForUser(user.id);
const { data } = await gmail.users.labels.list({ userId: "me" });
const labels = (data.labels || []).map((l) => ({
id: l.id,
name: l.name,
type: l.type,
}));
if (args.json) {
for (const l of labels) console.log(JSON.stringify(l));
} else {
for (const l of labels) {
console.log(`${l.id}\t${l.name}\t${l.type || ""}`);
}
}
audit({
userId: user.id,
mailbox: user.email,
action: "labels",
payload: { count: labels.length },
status: "ok",
});
}
async function cmdLabel(args) {
const reasonCheck = requireReason(args, "label");
if (!reasonCheck.ok) {
console.error(reasonCheck.error);
process.exit(1);
}
const user = resolveOrExit(args.user);
const ids = parseIds(args);
const add = args.add || [];
const remove = args.remove || [];
if (!ids.length) {
console.error("label requires at least one message id");
process.exit(1);
}
if (!add.length && !remove.length) {
console.error("label requires --add and/or --remove");
process.exit(1);
}
const body = labelModifyBody({ add, remove });
if (args.dryRun) {
console.log(
JSON.stringify({ dryRun: true, mailbox: user.email, ids, ...body }, null, 2),
);
audit({
userId: user.id,
mailbox: user.email,
action: "label",
reason: reasonCheck.reason,
messageIds: ids,
payload: { ...body, dryRun: true },
status: "dry-run",
});
return;
}
const gmail = await gmailClientForUser(user.id);
try {
for (const id of ids) {
await gmail.users.messages.modify({
userId: "me",
id,
requestBody: body,
});
}
console.log(`Labeled ${ids.length} message(s)`);
audit({
userId: user.id,
mailbox: user.email,
action: "label",
reason: reasonCheck.reason,
messageIds: ids,
payload: body,
status: "ok",
});
} catch (err) {
audit({
userId: user.id,
mailbox: user.email,
action: "label",
reason: reasonCheck.reason,
messageIds: ids,
payload: body,
status: "error",
error: err.message || String(err),
});
throw err;
}
}
async function cmdArchive(args) {
const reasonCheck = requireReason(args, "archive");
if (!reasonCheck.ok) {
console.error(reasonCheck.error);
process.exit(1);
}
const user = resolveOrExit(args.user);
const ids = parseIds(args);
if (!ids.length) {
console.error("archive requires at least one message id");
process.exit(1);
}
const body = archiveModifyBody();
if (args.dryRun) {
console.log(
JSON.stringify({ dryRun: true, mailbox: user.email, ids, ...body }, null, 2),
);
audit({
userId: user.id,
mailbox: user.email,
action: "archive",
reason: reasonCheck.reason,
messageIds: ids,
payload: { ...body, dryRun: true },
status: "dry-run",
});
return;
}
const gmail = await gmailClientForUser(user.id);
try {
for (const id of ids) {
await gmail.users.messages.modify({
userId: "me",
id,
requestBody: body,
});
}
console.log(`Archived ${ids.length} message(s)`);
audit({
userId: user.id,
mailbox: user.email,
action: "archive",
reason: reasonCheck.reason,
messageIds: ids,
payload: body,
status: "ok",
});
} catch (err) {
audit({
userId: user.id,
mailbox: user.email,
action: "archive",
reason: reasonCheck.reason,
messageIds: ids,
payload: body,
status: "error",
error: err.message || String(err),
});
throw err;
}
}
async function cmdDraft(args) {
const reasonCheck = requireReason(args, "draft");
if (!reasonCheck.ok) {
console.error(reasonCheck.error);
process.exit(1);
}
if (!args.to || !args.subject) {
console.error("draft requires --to and --subject");
process.exit(1);
}
const user = resolveOrExit(args.user);
const bodyText = args.body || "";
const raw = buildDraftRaw({
to: args.to,
subject: args.subject,
body: bodyText,
cc: args.cc || undefined,
inReplyTo: args.inReplyTo || undefined,
references: args.references || undefined,
});
const message = rawToGmailMessage(raw);
if (args.dryRun) {
console.log(
JSON.stringify(
{
dryRun: true,
mailbox: user.email,
to: args.to,
subject: args.subject,
body: bodyText,
cc: args.cc || null,
},
null,
2,
),
);
audit({
userId: user.id,
mailbox: user.email,
action: "draft",
reason: reasonCheck.reason,
payload: {
to: args.to,
subject: args.subject,
dryRun: true,
},
status: "dry-run",
});
return;
}
const gmail = await gmailClientForUser(user.id);
try {
const { data } = await gmail.users.drafts.create({
userId: "me",
requestBody: { message },
});
console.log(
JSON.stringify({
draftId: data.id,
messageId: data.message?.id || null,
to: args.to,
subject: args.subject,
}),
);
audit({
userId: user.id,
mailbox: user.email,
action: "draft",
reason: reasonCheck.reason,
messageIds: data.message?.id ? [data.message.id] : [],
payload: {
draftId: data.id,
to: args.to,
subject: args.subject,
},
status: "ok",
});
} catch (err) {
audit({
userId: user.id,
mailbox: user.email,
action: "draft",
reason: reasonCheck.reason,
payload: { to: args.to, subject: args.subject },
status: "error",
error: err.message || String(err),
});
throw err;
}
}
async function cmdForward(args) {
const reasonCheck = requireReason(args, "forward");
if (!reasonCheck.ok) {
console.error(reasonCheck.error);
process.exit(1);
}
if (!args.to) {
console.error("forward requires --to");
process.exit(1);
}
const ids = parseIds(args);
if (ids.length !== 1) {
console.error("forward requires exactly one message id");
process.exit(1);
}
const user = resolveOrExit(args.user);
const gmail = await gmailClientForUser(user.id);
const original = await fetchFull(gmail, ids[0]);
const raw = buildForwardDraftRaw({
to: args.to,
original: {
from: original.from,
to: original.to,
subject: original.subject,
date: original.date,
body: original.body,
},
note: args.note || undefined,
cc: args.cc || undefined,
});
const message = rawToGmailMessage(raw);
const subject = original.subject?.toLowerCase().startsWith("fwd:")
? original.subject
: `Fwd: ${original.subject}`;
if (args.dryRun) {
console.log(
JSON.stringify(
{
dryRun: true,
mailbox: user.email,
sourceId: original.id,
to: args.to,
subject,
note: args.note || null,
},
null,
2,
),
);
audit({
userId: user.id,
mailbox: user.email,
action: "forward",
reason: reasonCheck.reason,
messageIds: [original.id],
payload: { to: args.to, subject, dryRun: true },
status: "dry-run",
});
return;
}
try {
const { data } = await gmail.users.drafts.create({
userId: "me",
requestBody: { message },
});
console.log(
JSON.stringify({
draftId: data.id,
messageId: data.message?.id || null,
sourceId: original.id,
to: args.to,
subject,
}),
);
audit({
userId: user.id,
mailbox: user.email,
action: "forward",
reason: reasonCheck.reason,
messageIds: [original.id],
payload: {
draftId: data.id,
to: args.to,
subject,
},
status: "ok",
});
} catch (err) {
audit({
userId: user.id,
mailbox: user.email,
action: "forward",
reason: reasonCheck.reason,
messageIds: [original.id],
payload: { to: args.to, subject },
status: "error",
error: err.message || String(err),
});
throw err;
}
}
async function cmdAudit(args) {
let userId = null;
let mailboxFilter = null;
if (args.user) {
const user = resolveOrExit(args.user);
userId = user.id;
mailboxFilter = user.email;
}
const rows = listAuditLog({
userId,
action: args.action || null,
limit: args.limit || 50,
});
if (args.json) {
for (const row of rows) {
console.log(
JSON.stringify({
...row,
message_ids: row.message_ids ? JSON.parse(row.message_ids) : null,
payload: row.payload ? JSON.parse(row.payload) : null,
}),
);
}
} else {
if (mailboxFilter) {
console.error(`# mailbox=${mailboxFilter} count=${rows.length}`);
}
for (const row of rows) {
const reason = row.reason ? ` reason=${JSON.stringify(row.reason)}` : "";
console.log(
`${row.id}\t${row.created_at}\t${row.mailbox}\t${row.action}\t${row.status}${reason}`,
);
}
}
}
async function main() {
const argv = process.argv.slice(2);
const command = argv[0];
if (!command || command === "--help" || command === "-h") {
printHelp();
process.exit(command ? 0 : 1);
}
const rest = argv.slice(1);
const args = parseArgv(rest, {
booleans: ["json", "dryRun"],
strings: [
"user",
"query",
"max",
"reason",
"to",
"subject",
"body",
"cc",
"note",
"default",
"action",
"limit",
"inReplyTo",
"references",
],
multi: ["ids", "add", "remove"],
});
// Accept --dry-run as dryRun via camelCase from parseArgv... --dry-run → dryRun
// parseArgv converts dry-run to dryRun via camel — good.
if (args.help) {
printHelp();
process.exit(0);
}
if (MUTATING.has(command) && !(args.reason || "").trim()) {
console.error(`--reason is required for ${command}`);
process.exit(1);
}
if (
["list", "read", "summarize", "labels", "label", "archive", "draft", "forward"].includes(
command,
)
) {
ensureGoogleCreds();
}
switch (command) {
case "accounts":
await cmdAccounts(args);
break;
case "list":
await cmdList(args);
break;
case "read":
await cmdRead(args);
break;
case "summarize":
await cmdSummarize(args);
break;
case "labels":
await cmdLabels(args);
break;
case "label":
await cmdLabel(args);
break;
case "archive":
await cmdArchive(args);
break;
case "draft":
await cmdDraft(args);
break;
case "forward":
await cmdForward(args);
break;
case "audit":
await cmdAudit(args);
break;
default:
console.error(`Unknown command: ${command}`);
printHelp();
process.exit(1);
}
}
main().catch((err) => {
console.error(err.message || err);
process.exit(1);
});
+16 -127
View File
@@ -1,140 +1,29 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* List Gmail messages: id, from, subject (metadata only). * List Gmail messages: id, from, subject (metadata only).
* Thin wrapper around: npm run gmail -- list …
* *
* Usage: * Usage:
* npm run list-messages -w watcher * npm run list-messages --
* npm run list-messages -w watcher -- --query 'newer_than:14d has:attachment filename:eml' * npm run list-messages -- --query 'newer_than:14d' --max 100
* npm run list-messages -w watcher -- --max 100 --json * npm run list-messages -- --json
* *
* Options: * Options:
* --query <q> Gmail search (default: newer_than:7d) * --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) * --max <n> Max messages (default 50)
* --json Print JSON lines instead of a table * --json Print JSON lines instead of a table
*/ */
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } from "../src/config.js"; import { spawn } from "node:child_process";
import { getUserByEmail, listUsersWithTokens } from "../src/db.js"; import { fileURLToPath } from "node:url";
import { gmailClientForUser } from "../src/google.js"; import { dirname, resolve } from "node:path";
import { header } from "../src/mime.js";
const DEFAULT_QUERY = "newer_than:7d"; const __dirname = dirname(fileURLToPath(import.meta.url));
const gmailJs = resolve(__dirname, "gmail.js");
function parseArgs(argv) { const child = spawn(
const out = { process.execPath,
query: null, [gmailJs, "list", ...process.argv.slice(2)],
user: null, { stdio: "inherit" },
max: 50, );
json: false, child.on("exit", (code) => process.exit(code ?? 1));
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);
});
+6 -16
View File
@@ -16,7 +16,7 @@
* Options: * Options:
* --query <q> Gmail search (default: in:inbox newer_than:30d) * --query <q> Gmail search (default: in:inbox newer_than:30d)
* --ids <a,b> Explicit message IDs (skips list search) * --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) * --max <n> Max messages to process (default 500)
* --dry-run Parse and log only; do not write PocketBase * --dry-run Parse and log only; do not write PocketBase
*/ */
@@ -26,8 +26,8 @@ import {
GOOGLE_CLIENT_SECRET, GOOGLE_CLIENT_SECRET,
POCKETBASE_URL, POCKETBASE_URL,
} from "../src/config.js"; } from "../src/config.js";
import { getUserByEmail, listUsersWithTokens } from "../src/db.js";
import { gmailClientForUser } from "../src/google.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 { fetchAndLogMessage } from "../src/handlers/fetch-and-log-message.js";
import { matchRule } from "../src/handlers/match-rule.js"; import { matchRule } from "../src/handlers/match-rule.js";
import { parseMatchedMessage } from "../src/handlers/parse-matched-message.js"; import { parseMatchedMessage } from "../src/handlers/parse-matched-message.js";
@@ -128,22 +128,12 @@ Default query: ${DEFAULT_QUERY}`);
process.exit(1); process.exit(1);
} }
const users = listUsersWithTokens(); const resolved = resolveUser(args.user);
if (!users.length) { if (!resolved.ok) {
console.error("No signed-in users with tokens. Sign in via the web UI first."); console.error(resolved.error);
process.exit(1); process.exit(1);
} }
const user = resolved.user;
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 log = makeLog(); const log = makeLog();
const gmail = await gmailClientForUser(user.id); const gmail = await gmailClientForUser(user.id);
+95
View File
@@ -0,0 +1,95 @@
/**
* Minimal shared argv parser for gmail CLI commands.
* Supports --flag value, --flag=value, and boolean --flags.
*/
export function parseArgv(argv, { booleans = [], strings = [], multi = [] } = {}) {
const out = {
_: [],
help: false,
};
for (const key of booleans) out[key] = false;
for (const key of strings) out[key] = null;
for (const key of multi) out[key] = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--help" || a === "-h") {
out.help = true;
continue;
}
if (!a.startsWith("--")) {
out._.push(a);
continue;
}
const eq = a.indexOf("=");
let name;
let rawValue;
if (eq >= 0) {
name = a.slice(2, eq);
rawValue = a.slice(eq + 1);
} else {
name = a.slice(2);
rawValue = undefined;
}
const camel = name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
if (booleans.includes(camel) || booleans.includes(name)) {
const key = booleans.includes(camel) ? camel : name;
out[key] = true;
continue;
}
if (multi.includes(camel) || multi.includes(name)) {
const key = multi.includes(camel) ? camel : name;
const value = rawValue !== undefined ? rawValue : argv[++i];
if (value == null) continue;
out[key].push(
...String(value)
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
continue;
}
if (strings.includes(camel) || strings.includes(name)) {
const key = strings.includes(camel) ? camel : name;
out[key] = rawValue !== undefined ? rawValue : argv[++i];
continue;
}
// Unknown flag: treat as string if next token looks like a value
if (rawValue !== undefined) {
out[camel] = rawValue;
} else if (argv[i + 1] && !argv[i + 1].startsWith("--")) {
out[camel] = argv[++i];
} else {
out[camel] = true;
}
}
return out;
}
export function requireReason(args, action) {
const reason = (args.reason || "").trim();
if (!reason) {
return {
ok: false,
error: `--reason is required for ${action}`,
};
}
return { ok: true, reason };
}
export function parseIds(args) {
const fromFlag = Array.isArray(args.ids) ? args.ids : [];
const fromPositional = args._ || [];
const joined = [...fromFlag, ...fromPositional]
.flatMap((s) => String(s).split(","))
.map((s) => s.trim())
.filter(Boolean);
return [...new Set(joined)];
}
+74
View File
@@ -0,0 +1,74 @@
import { GMAIL_DEFAULT_USER } from "../config.js";
import {
getDefaultUser,
getUserByEmail,
getUserById,
listUsersWithTokens,
} from "../db.js";
/**
* Resolve which connected mailbox a CLI command should use.
*
* Order: --user → GMAIL_DEFAULT_USER env → users.is_default → sole connected user.
*
* @param {string|null|undefined} userFlag
* @param {object} [deps] injectable for tests
* @returns {{ ok: true, user: object } | { ok: false, error: string }}
*/
export function resolveUser(userFlag, deps = {}) {
const listUsers = deps.listUsers || listUsersWithTokens;
const getByEmail = deps.getByEmail || getUserByEmail;
const getById = deps.getById || getUserById;
const getDefault = deps.getDefault || getDefaultUser;
const envDefault =
deps.envDefault !== undefined ? deps.envDefault : GMAIL_DEFAULT_USER;
const users = listUsers();
if (!users.length) {
return {
ok: false,
error:
"No signed-in users with tokens. Sign in via the web UI first.",
};
}
const hasTokens = (user) => user && users.some((u) => u.id === user.id);
if (userFlag) {
const byEmail = getByEmail(userFlag);
const byId =
users.find((u) => String(u.id) === String(userFlag)) ||
getById(userFlag);
const user = byEmail || byId || null;
if (!user || !hasTokens(user)) {
return { ok: false, error: `User not found: ${userFlag}` };
}
return { ok: true, user };
}
if (envDefault) {
const fromEnv = getByEmail(envDefault);
if (!fromEnv || !hasTokens(fromEnv)) {
return {
ok: false,
error: `GMAIL_DEFAULT_USER not found or has no tokens: ${envDefault}`,
};
}
return { ok: true, user: fromEnv };
}
const flagged = getDefault();
if (flagged && hasTokens(flagged)) {
return { ok: true, user: flagged };
}
if (users.length === 1) {
return { ok: true, user: users[0] };
}
return {
ok: false,
error:
'Multiple accounts connected; set a default: npm run gmail -- accounts --default you@gmail.com',
};
}
+30
View File
@@ -20,6 +20,36 @@ export const GOOGLE_CLOUD_PROJECT =
export const PUBSUB_VERIFICATION_TOKEN = export const PUBSUB_VERIFICATION_TOKEN =
process.env.PUBSUB_VERIFICATION_TOKEN || ""; process.env.PUBSUB_VERIFICATION_TOKEN || "";
export const SQLITE_PATH = process.env.SQLITE_PATH || defaultSqlitePath; export const SQLITE_PATH = process.env.SQLITE_PATH || defaultSqlitePath;
/** Optional default mailbox email when scripts omit --user */
export const GMAIL_DEFAULT_USER = (process.env.GMAIL_DEFAULT_USER || "").trim();
/**
* Parse comma-separated mailbox allowlist for the receipt watcher.
* Empty / unset → empty Set (caller treats as "allow all").
*/
export function parseWatcherMailboxes(raw) {
const set = new Set();
for (const part of String(raw || "").split(",")) {
const email = part.trim().toLowerCase();
if (email) set.add(email);
}
return set;
}
/** Lowercased emails allowed for Pub/Sub receipt watch. Empty = all connected accounts. */
export const WATCHER_MAILBOXES = parseWatcherMailboxes(
process.env.WATCHER_MAILBOXES,
);
/**
* @param {string|null|undefined} email
* @param {Set<string>} [allowlist=WATCHER_MAILBOXES]
*/
export function isWatcherMailboxAllowed(email, allowlist = WATCHER_MAILBOXES) {
if (!allowlist || allowlist.size === 0) return true;
if (!email) return false;
return allowlist.has(String(email).trim().toLowerCase());
}
export const POCKETBASE_URL = (process.env.POCKETBASE_URL || "").replace( export const POCKETBASE_URL = (process.env.POCKETBASE_URL || "").replace(
/\/$/, /\/$/,
+101
View File
@@ -32,8 +32,31 @@ db.exec(`
history_id TEXT NOT NULL, history_id TEXT NOT NULL,
expiration INTEGER 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 getUserByIdStmt = db.prepare(`SELECT * FROM users WHERE id = ?`);
const getUserByEmailStmt = db.prepare( const getUserByEmailStmt = db.prepare(
`SELECT * FROM users WHERE LOWER(email) = LOWER(?)`, `SELECT * FROM users WHERE LOWER(email) = LOWER(?)`,
@@ -43,7 +66,19 @@ const listUsersWithTokensStmt = db.prepare(`
SELECT u.* SELECT u.*
FROM users u FROM users u
INNER JOIN oauth_tokens t ON t.user_id = u.id 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(` const saveTokensStmt = db.prepare(`
INSERT INTO oauth_tokens (user_id, access_token, refresh_token, expiry) INSERT INTO oauth_tokens (user_id, access_token, refresh_token, expiry)
VALUES (@userId, @accessToken, @refreshToken, @expiry) VALUES (@userId, @accessToken, @refreshToken, @expiry)
@@ -63,6 +98,22 @@ const saveWatchStateStmt = db.prepare(`
expiration = excluded.expiration 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) { export function getUserById(id) {
return getUserByIdStmt.get(id) || null; return getUserByIdStmt.get(id) || null;
} }
@@ -80,6 +131,19 @@ export function listUsersWithTokens() {
return listUsersWithTokensStmt.all(); 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 }) { export function saveTokens(userId, { accessToken, refreshToken, expiry }) {
saveTokensStmt.run({ saveTokensStmt.run({
userId, userId,
@@ -100,3 +164,40 @@ export function saveWatchState(userId, { historyId, expiration }) {
expiration: Number(expiration), expiration: Number(expiration),
}); });
} }
/**
* @param {object} entry
* @param {number|null} [entry.userId]
* @param {string} entry.mailbox
* @param {string} [entry.actor]
* @param {string} entry.action
* @param {string|null} [entry.reason]
* @param {string[]} [entry.messageIds]
* @param {object|null} [entry.payload]
* @param {string} entry.status
* @param {string|null} [entry.error]
*/
export function writeAuditLog(entry) {
const result = insertAuditStmt.run({
userId: entry.userId ?? null,
mailbox: entry.mailbox || "",
actor: entry.actor || "cli",
action: entry.action,
reason: entry.reason ?? null,
messageIds: entry.messageIds
? JSON.stringify(entry.messageIds)
: null,
payload: entry.payload != null ? JSON.stringify(entry.payload) : null,
status: entry.status,
error: entry.error ?? null,
});
return Number(result.lastInsertRowid);
}
export function listAuditLog({ userId = null, action = null, limit = 50 } = {}) {
return listAuditStmt.all({
userId: userId == null ? null : Number(userId),
action: action || null,
limit: Math.max(1, Math.min(500, Number(limit) || 50)),
});
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Build RFC822 raw message bodies for drafts (create only — never send).
*/
function encodeSubject(subject) {
// ASCII-safe; UTF-8 subjects use encoded-word if non-ascii
if (!/[^\x20-\x7E]/.test(subject || "")) return subject || "";
const b64 = Buffer.from(subject, "utf8").toString("base64");
return `=?UTF-8?B?${b64}?=`;
}
function encodeBase64Url(raw) {
return Buffer.from(raw, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
/**
* @param {{
* to: string,
* subject: string,
* body: string,
* cc?: string,
* inReplyTo?: string,
* references?: string,
* }} opts
*/
export function buildDraftRaw({
to,
subject,
body,
cc,
inReplyTo,
references,
}) {
const lines = [];
lines.push(`To: ${to}`);
if (cc) lines.push(`Cc: ${cc}`);
lines.push(`Subject: ${encodeSubject(subject)}`);
if (inReplyTo) lines.push(`In-Reply-To: ${inReplyTo}`);
if (references) lines.push(`References: ${references}`);
lines.push("MIME-Version: 1.0");
lines.push('Content-Type: text/plain; charset="UTF-8"');
lines.push("Content-Transfer-Encoding: 8bit");
lines.push("");
lines.push(body || "");
return lines.join("\r\n");
}
/**
* @param {{
* to: string,
* original: { from?: string, to?: string, subject?: string, date?: string, body?: string },
* note?: string,
* cc?: string,
* }} opts
*/
export function buildForwardDraftRaw({ to, original, note, cc }) {
const origSubject = original.subject || "(no subject)";
const subject = origSubject.toLowerCase().startsWith("fwd:")
? origSubject
: `Fwd: ${origSubject}`;
const parts = [];
if (note) {
parts.push(note.trim());
parts.push("");
}
parts.push("---------- Forwarded message ---------");
if (original.from) parts.push(`From: ${original.from}`);
if (original.date) parts.push(`Date: ${original.date}`);
parts.push(`Subject: ${origSubject}`);
if (original.to) parts.push(`To: ${original.to}`);
parts.push("");
parts.push(original.body || "");
return buildDraftRaw({
to,
subject,
body: parts.join("\n"),
cc,
});
}
export function rawToGmailMessage(raw) {
return { raw: encodeBase64Url(raw) };
}
/** Request body for archive: remove INBOX label. */
export function archiveModifyBody() {
return { removeLabelIds: ["INBOX"] };
}
/** Request body for label add/remove. */
export function labelModifyBody({ add = [], remove = [] } = {}) {
const body = {};
if (add.length) body.addLabelIds = add;
if (remove.length) body.removeLabelIds = remove;
return body;
}
@@ -1,4 +1,5 @@
import { parseBriQris, parseBriTransfer } from "../parsers/bri.js"; import { parseBriQris, parseBriTransfer } from "../parsers/bri.js";
import { parseFlipTransfer } from "../parsers/flip.js";
import { parseGrabReceipt } from "../parsers/grab.js"; import { parseGrabReceipt } from "../parsers/grab.js";
import { parseLivinReceipt } from "../parsers/livin.js"; import { parseLivinReceipt } from "../parsers/livin.js";
import { parseTokopediaOrder } from "../parsers/tokopedia.js"; import { parseTokopediaOrder } from "../parsers/tokopedia.js";
@@ -10,6 +11,7 @@ const PARSERS = {
"bri.transfer.success": parseBriTransfer, "bri.transfer.success": parseBriTransfer,
"bri.qris.success": parseBriQris, "bri.qris.success": parseBriQris,
"tokopedia.order.completed": parseTokopediaOrder, "tokopedia.order.completed": parseTokopediaOrder,
"flip.transfer.success": parseFlipTransfer,
}; };
/** /**
+23 -1
View File
@@ -1,5 +1,12 @@
import Fastify from "fastify"; import Fastify from "fastify";
import { GOOGLE_PUBSUB_TOPIC, PORT, WATCH_RENEW_MS, assertConfig } from "./config.js"; import {
GOOGLE_PUBSUB_TOPIC,
PORT,
WATCH_RENEW_MS,
WATCHER_MAILBOXES,
assertConfig,
isWatcherMailboxAllowed,
} from "./config.js";
import { listUsersWithTokens } from "./db.js"; import { listUsersWithTokens } from "./db.js";
import { startWatch } from "./google.js"; import { startWatch } from "./google.js";
import { fetchAndLogMessage } from "./handlers/fetch-and-log-message.js"; import { fetchAndLogMessage } from "./handlers/fetch-and-log-message.js";
@@ -44,7 +51,22 @@ async function renewAllWatches() {
app.log.warn("gmail-watch: no signed-in users with tokens"); app.log.warn("gmail-watch: no signed-in users with tokens");
return; return;
} }
if (WATCHER_MAILBOXES.size > 0) {
app.log.info(
{ allowlist: [...WATCHER_MAILBOXES] },
"gmail-watch: WATCHER_MAILBOXES restrict receipt watches",
);
}
for (const user of users) { for (const user of users) {
if (!isWatcherMailboxAllowed(user.email)) {
app.log.info(
{ userId: user.id, email: user.email },
"gmail-watch: skip watch (not in WATCHER_MAILBOXES)",
);
continue;
}
try { try {
await startWatch(user.id); await startWatch(user.id);
app.log.info( app.log.info(
+74
View File
@@ -0,0 +1,74 @@
import {
captureBlockAfterLabel,
parseDayMonthYear,
parseIdrAmount,
parseTimeWib,
toWibIso,
} from "../lib/parse-helpers.js";
/**
* First non-empty line after a Flip stacked label (value on the next line).
*/
function valueAfterLabel(body, label, nextLabels = []) {
const lines = captureBlockAfterLabel(body, label, nextLabels);
return lines?.[0]?.trim() || null;
}
/**
* Parse Flip transfer-success body (plain text, stacked label/value lines).
* Required: Nominal + ID Transaksi.
*/
export function parseFlipTransfer(body) {
if (!body) return null;
const referenceRaw = valueAfterLabel(body, "ID Transaksi", [
"Waktu Terkirim",
"Nama Tujuan",
]);
const reference = referenceRaw
? referenceRaw.replace(/^#/, "").trim()
: null;
const waktuRaw = valueAfterLabel(body, "Waktu Terkirim", [
"Nama Tujuan",
"Bank Tujuan",
]);
const dateYmd = parseDayMonthYear(waktuRaw);
const timeHms = parseTimeWib(waktuRaw);
const occurredAt = toWibIso(dateYmd, timeHms);
const amount = parseIdrAmount(
valueAfterLabel(body, "Nominal", [
"Kalau ada pertanyaan",
"Makasih",
"Salam",
]),
);
if (amount == null || !reference) return null;
const namaTujuan = valueAfterLabel(body, "Nama Tujuan", [
"Bank Tujuan",
"Nomor Rekening Tujuan",
]);
const bankTujuan = valueAfterLabel(body, "Bank Tujuan", [
"Nomor Rekening Tujuan",
"Nominal",
]);
const nomorRekening = valueAfterLabel(body, "Nomor Rekening Tujuan", [
"Nominal",
]);
const details = {};
if (namaTujuan) details.namaTujuan = namaTujuan;
if (bankTujuan) details.bankTujuan = bankTujuan;
if (nomorRekening) details.nomorRekening = nomorRekening;
return {
amount,
currency: "IDR",
reference,
occurredAt,
details,
};
}
+2
View File
@@ -24,6 +24,8 @@ export function toSpendingRecord(parsed, rule) {
? `${base} tip` ? `${base} tip`
: base; : base;
} }
} else if (source === "flip") {
description = parsed.details?.namaTujuan || "";
} }
return { return {
+9 -1
View File
@@ -1,5 +1,5 @@
import { timingSafeEqual } from "node:crypto"; import { timingSafeEqual } from "node:crypto";
import { PUBSUB_VERIFICATION_TOKEN } from "../config.js"; import { PUBSUB_VERIFICATION_TOKEN, isWatcherMailboxAllowed } from "../config.js";
import { getUserByEmail, getWatchState, saveWatchState } from "../db.js"; import { getUserByEmail, getWatchState, saveWatchState } from "../db.js";
import { import {
gmailClientForUser, gmailClientForUser,
@@ -71,6 +71,14 @@ function decodeNotification(body) {
} }
async function processNotification(log, { fetchHandlers, processHandlers }, notification) { async function processNotification(log, { fetchHandlers, processHandlers }, notification) {
if (!isWatcherMailboxAllowed(notification.emailAddress)) {
log.warn(
{ email: notification.emailAddress },
"gmail-watch: mailbox not in WATCHER_MAILBOXES, acking",
);
return;
}
const user = getUserByEmail(notification.emailAddress); const user = getUserByEmail(notification.emailAddress);
if (!user) { if (!user) {
log.warn( log.warn(
+6
View File
@@ -0,0 +1,6 @@
export const flipTransfer = {
kind: "flip.transfer.success",
source: "flip",
fromAddress: "no-reply@flip.id",
subject: "Transfer ke",
};
+2
View File
@@ -1,5 +1,6 @@
import { briQris } from "./bri-qris.js"; import { briQris } from "./bri-qris.js";
import { briTransfer } from "./bri-transfer.js"; import { briTransfer } from "./bri-transfer.js";
import { flipTransfer } from "./flip-transfer.js";
import { grabEreceipt } from "./grab-ereceipt.js"; import { grabEreceipt } from "./grab-ereceipt.js";
import { livinQrPayment } from "./livin-qr-payment.js"; import { livinQrPayment } from "./livin-qr-payment.js";
import { tokopediaOrder } from "./tokopedia-order.js"; import { tokopediaOrder } from "./tokopedia-order.js";
@@ -10,4 +11,5 @@ export const rules = [
briTransfer, briTransfer,
briQris, briQris,
tokopediaOrder, tokopediaOrder,
flipTransfer,
]; ];
+111
View File
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import Database from "better-sqlite3";
/**
* Exercise audit_log insert/list against an in-memory schema that mirrors
* apps/watcher/src/db.js (without opening the live app.db).
*/
function openAuditDb() {
const db = new Database(":memory:");
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
google_sub TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
name TEXT,
picture TEXT,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
mailbox TEXT NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
reason TEXT,
message_ids TEXT,
payload TEXT,
status TEXT NOT NULL,
error TEXT
);
`);
return db;
}
describe("audit_log schema behavior", () => {
it("inserts and lists with reason + status", () => {
const db = openAuditDb();
const { lastInsertRowid: userId } = db
.prepare(
`INSERT INTO users (google_sub, email, is_default) VALUES ('sub1', 'a@example.com', 1)`,
)
.run();
const insert = db.prepare(`
INSERT INTO audit_log (
user_id, mailbox, actor, action, reason, message_ids, payload, status, error
) VALUES (?, ?, 'cli', ?, ?, ?, ?, ?, ?)
`);
insert.run(
userId,
"a@example.com",
"archive",
"newsletter noise",
JSON.stringify(["msg1"]),
JSON.stringify({ removeLabelIds: ["INBOX"] }),
"ok",
null,
);
insert.run(
userId,
"a@example.com",
"archive",
"preview",
JSON.stringify(["msg2"]),
JSON.stringify({ dryRun: true }),
"dry-run",
null,
);
const rows = db
.prepare(
`SELECT * FROM audit_log WHERE action = ? ORDER BY id DESC LIMIT 10`,
)
.all("archive");
assert.equal(rows.length, 2);
assert.equal(rows[0].status, "dry-run");
assert.equal(rows[1].reason, "newsletter noise");
assert.deepEqual(JSON.parse(rows[1].message_ids), ["msg1"]);
});
it("setDefault clears other defaults", () => {
const db = openAuditDb();
db.prepare(
`INSERT INTO users (google_sub, email, is_default) VALUES ('s1', 'a@example.com', 1)`,
).run();
db.prepare(
`INSERT INTO users (google_sub, email, is_default) VALUES ('s2', 'b@example.com', 0)`,
).run();
const clearAndSet = db.transaction((id) => {
db.prepare(`UPDATE users SET is_default = 0`).run();
db.prepare(`UPDATE users SET is_default = 1 WHERE id = ?`).run(id);
});
const b = db
.prepare(`SELECT id FROM users WHERE email = 'b@example.com'`)
.get();
clearAndSet(b.id);
const defaults = db
.prepare(`SELECT email FROM users WHERE is_default = 1`)
.all();
assert.deepEqual(
defaults.map((r) => r.email),
["b@example.com"],
);
});
});
+208
View File
@@ -0,0 +1,208 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { resolveUser } from "../src/cli/resolve-user.js";
import { parseArgv, parseIds, requireReason } from "../src/cli/parse-args.js";
import {
archiveModifyBody,
buildDraftRaw,
buildForwardDraftRaw,
labelModifyBody,
rawToGmailMessage,
} from "../src/gmail/draft.js";
function usersFixture() {
return [
{ id: 1, email: "a@example.com", is_default: 0 },
{ id: 2, email: "b@example.com", is_default: 1 },
];
}
describe("resolveUser", () => {
it("uses --user email", () => {
const users = usersFixture();
const result = resolveUser("a@example.com", {
listUsers: () => users,
getByEmail: (e) => users.find((u) => u.email === e) || null,
getById: (id) => users.find((u) => u.id === Number(id)) || null,
getDefault: () => users.find((u) => u.is_default) || null,
envDefault: "",
});
assert.equal(result.ok, true);
assert.equal(result.user.email, "a@example.com");
});
it("uses --user id", () => {
const users = usersFixture();
const result = resolveUser("2", {
listUsers: () => users,
getByEmail: () => null,
getById: (id) => users.find((u) => u.id === Number(id)) || null,
getDefault: () => null,
envDefault: "",
});
assert.equal(result.ok, true);
assert.equal(result.user.id, 2);
});
it("uses GMAIL_DEFAULT_USER over is_default", () => {
const users = usersFixture();
const result = resolveUser(null, {
listUsers: () => users,
getByEmail: (e) => users.find((u) => u.email === e) || null,
getById: () => null,
getDefault: () => users.find((u) => u.is_default) || null,
envDefault: "a@example.com",
});
assert.equal(result.ok, true);
assert.equal(result.user.email, "a@example.com");
});
it("uses is_default when no flag/env", () => {
const users = usersFixture();
const result = resolveUser(null, {
listUsers: () => users,
getByEmail: () => null,
getById: () => null,
getDefault: () => users.find((u) => u.is_default) || null,
envDefault: "",
});
assert.equal(result.ok, true);
assert.equal(result.user.email, "b@example.com");
});
it("uses sole connected user", () => {
const users = [{ id: 9, email: "only@example.com", is_default: 0 }];
const result = resolveUser(null, {
listUsers: () => users,
getByEmail: () => null,
getById: () => null,
getDefault: () => null,
envDefault: "",
});
assert.equal(result.ok, true);
assert.equal(result.user.email, "only@example.com");
});
it("fails when multiple and no default", () => {
const users = [
{ id: 1, email: "a@example.com", is_default: 0 },
{ id: 2, email: "b@example.com", is_default: 0 },
];
const result = resolveUser(null, {
listUsers: () => users,
getByEmail: () => null,
getById: () => null,
getDefault: () => null,
envDefault: "",
});
assert.equal(result.ok, false);
assert.match(result.error, /Multiple accounts/);
});
it("fails when no users", () => {
const result = resolveUser(null, {
listUsers: () => [],
getByEmail: () => null,
getById: () => null,
getDefault: () => null,
envDefault: "",
});
assert.equal(result.ok, false);
assert.match(result.error, /No signed-in users/);
});
});
describe("requireReason", () => {
it("rejects missing reason", () => {
const r = requireReason({ reason: " " }, "archive");
assert.equal(r.ok, false);
assert.match(r.error, /--reason is required/);
});
it("accepts non-empty reason", () => {
const r = requireReason({ reason: "noise" }, "archive");
assert.equal(r.ok, true);
assert.equal(r.reason, "noise");
});
});
describe("parseArgv / parseIds", () => {
it("parses dry-run and multi ids", () => {
const args = parseArgv(
["abc", "--dry-run", "--ids", "x,y", "--reason", "why"],
{
booleans: ["dryRun"],
strings: ["reason"],
multi: ["ids"],
},
);
assert.equal(args.dryRun, true);
assert.deepEqual(args.ids, ["x", "y"]);
assert.equal(args.reason, "why");
assert.deepEqual(parseIds(args), ["x", "y", "abc"]);
});
});
describe("draft MIME helpers", () => {
it("buildDraftRaw includes headers and body", () => {
const raw = buildDraftRaw({
to: "a@b.com",
subject: "Hello",
body: "Line1\nLine2",
cc: "c@d.com",
});
assert.match(raw, /^To: a@b.com\r\n/);
assert.match(raw, /Cc: c@d.com/);
assert.match(raw, /Subject: Hello/);
assert.match(raw, /Line1\nLine2$/);
});
it("buildForwardDraftRaw prefixes Fwd and quotes original", () => {
const raw = buildForwardDraftRaw({
to: "acct@example.com",
note: "Please book.",
original: {
from: "grab@example.com",
to: "me@example.com",
subject: "Your Grab E-Receipt",
date: "Mon, 1 Jan 2026",
body: "Total Rp10.000",
},
});
assert.match(raw, /Subject: Fwd: Your Grab E-Receipt/);
assert.match(raw, /Please book\./);
assert.match(raw, /---------- Forwarded message ---------/);
assert.match(raw, /From: grab@example.com/);
assert.match(raw, /Total Rp10\.000/);
});
it("does not double Fwd: prefix", () => {
const raw = buildForwardDraftRaw({
to: "a@b.com",
original: { subject: "Fwd: Already", body: "x" },
});
assert.match(raw, /Subject: Fwd: Already/);
assert.doesNotMatch(raw, /Subject: Fwd: Fwd:/);
});
it("rawToGmailMessage base64url-encodes", () => {
const { raw } = rawToGmailMessage("hi+/=?");
assert.equal(raw.includes("+"), false);
assert.equal(raw.includes("/"), false);
assert.ok(raw.length > 0);
});
it("archiveModifyBody removes INBOX only", () => {
assert.deepEqual(archiveModifyBody(), { removeLabelIds: ["INBOX"] });
});
it("labelModifyBody adds and removes", () => {
assert.deepEqual(labelModifyBody({ add: ["Label_1"], remove: ["INBOX"] }), {
addLabelIds: ["Label_1"],
removeLabelIds: ["INBOX"],
});
assert.deepEqual(labelModifyBody({ add: ["A"] }), {
addLabelIds: ["A"],
});
});
});
+76
View File
@@ -411,3 +411,79 @@ Tujuan Pengiriman
Roby Roby
`; `;
export const flipDirectBody = `BUKTI TRANSFER
Halo, Kak Nasyarobby.
Transfer ke tujuan berhasil diproses. Ini detail transaksinya:
ID Transaksi
#ST260918145355861M3ZG4
Waktu Terkirim
18 Sep 2026 14:53 WIB
Nama Tujuan
Rwen Ibrahim
Bank Tujuan
BCA
Nomor Rekening Tujuan
0020215810
Nominal
Rp40.001
Kalau ada pertanyaan, silakan hubungi Tim Flip lewat menu *Bantuan*
(pilih *Chat
Tim Flip*) atau klik link flip.id/bantuan.
------------------------------
Makasih udah pakai Flip!
Salam hangat,
Tim Flip`;
export const flipForwardedBody = `---------- Forwarded message ---------
From: Flip <no-reply@flip.id>
Date: Fri, Sep 18, 2026 at 2:54 PM
Subject: Transfer ke Rwen Ibrahim berhasil
To: <nasyarobby@gmail.com>
BUKTI TRANSFER
Halo, Kak Nasyarobby.
Transfer ke tujuan berhasil diproses. Ini detail transaksinya:
ID Transaksi
#ST260918145355861M3ZG4
Waktu Terkirim
18 Sep 2026 14:53 WIB
Nama Tujuan
Rwen Ibrahim
Bank Tujuan
BCA
Nomor Rekening Tujuan
0020215810
Nominal
Rp40.001
Kalau ada pertanyaan, silakan hubungi Tim Flip lewat menu *Bantuan*
(pilih *Chat
Tim Flip*) atau klik link flip.id/bantuan.
------------------------------
Makasih udah pakai Flip!
Salam hangat,
Tim Flip
Tidak membuat permintaan ini? Hubungi kami melalui aplikasi lewat ke menu
*Bantuan* (pilih *Chat Tim Flip*) atau klik link flip.id/bantuan.
Copyright © 2025 PT. Fliptech Lentera Inspirasi Pertiwi. All Rights
Reserved.
--
Regards,
*Nasyarobby NP*
`;
+28
View File
@@ -12,6 +12,8 @@ import {
briQrisForwardedBody, briQrisForwardedBody,
briTransferDirectBody, briTransferDirectBody,
briTransferForwardedBody, briTransferForwardedBody,
flipDirectBody,
flipForwardedBody,
grabBody, grabBody,
grabForwardedBody, grabForwardedBody,
livinDirectBody, livinDirectBody,
@@ -223,6 +225,32 @@ describe("matchRule", () => {
assert.equal(result.pass.rule.kind, "tokopedia.order.completed"); assert.equal(result.pass.rule.kind, "tokopedia.order.completed");
}); });
it("matches direct Flip transfer", async () => {
const result = await matchRule({
messageId: "flip-direct",
log: silentLog,
message: {
from: "Flip <no-reply@flip.id>",
subject: "Transfer ke Rwen Ibrahim berhasil",
body: flipDirectBody,
},
});
assert.equal(result.pass.rule.kind, "flip.transfer.success");
});
it("matches forwarded Flip transfer via original header", async () => {
const result = await matchRule({
messageId: "1a0c704164e0cd54",
log: silentLog,
message: {
from: "NSRB <nasyarobby@gmail.com>",
subject: "Fwd: Transfer ke Rwen Ibrahim berhasil",
body: flipForwardedBody,
},
});
assert.equal(result.pass.rule.kind, "flip.transfer.success");
});
it("breaks on unknown from/subject", async () => { it("breaks on unknown from/subject", async () => {
const result = await matchRule({ const result = await matchRule({
messageId: "x", messageId: "x",
+56
View File
@@ -1,12 +1,14 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { parseBriQris, parseBriTransfer } from "../src/parsers/bri.js"; import { parseBriQris, parseBriTransfer } from "../src/parsers/bri.js";
import { parseFlipTransfer } from "../src/parsers/flip.js";
import { parseGrabReceipt, extractGrabTripTimesFromHtml } from "../src/parsers/grab.js"; import { parseGrabReceipt, extractGrabTripTimesFromHtml } from "../src/parsers/grab.js";
import { parseLivinReceipt, splitMerchantLocation } from "../src/parsers/livin.js"; import { parseLivinReceipt, splitMerchantLocation } from "../src/parsers/livin.js";
import { parseTokopediaOrder } from "../src/parsers/tokopedia.js"; import { parseTokopediaOrder } from "../src/parsers/tokopedia.js";
import { toSpendingRecord, toPocketBaseDate } from "../src/pocketbase/map.js"; import { toSpendingRecord, toPocketBaseDate } from "../src/pocketbase/map.js";
import { briQris } from "../src/rules/bri-qris.js"; import { briQris } from "../src/rules/bri-qris.js";
import { briTransfer } from "../src/rules/bri-transfer.js"; import { briTransfer } from "../src/rules/bri-transfer.js";
import { flipTransfer } from "../src/rules/flip-transfer.js";
import { livinQrPayment } from "../src/rules/livin-qr-payment.js"; import { livinQrPayment } from "../src/rules/livin-qr-payment.js";
import { grabEreceipt } from "../src/rules/grab-ereceipt.js"; import { grabEreceipt } from "../src/rules/grab-ereceipt.js";
import { tokopediaOrder } from "../src/rules/tokopedia-order.js"; import { tokopediaOrder } from "../src/rules/tokopedia-order.js";
@@ -15,6 +17,8 @@ import {
briQrisForwardedBody, briQrisForwardedBody,
briTransferDirectBody, briTransferDirectBody,
briTransferForwardedBody, briTransferForwardedBody,
flipDirectBody,
flipForwardedBody,
grabBody, grabBody,
grabFoodForwardedBody, grabFoodForwardedBody,
grabForwardedBody, grabForwardedBody,
@@ -366,6 +370,58 @@ describe("toSpendingRecord", () => {
assert.equal(rec.description, "LuxePad.Creations - Tokopedia"); assert.equal(rec.description, "LuxePad.Creations - Tokopedia");
assert.equal(rec.trx_date, "2026-09-05 00:00:00.000Z"); assert.equal(rec.trx_date, "2026-09-05 00:00:00.000Z");
}); });
it("maps Flip transfer envelope to spendings fields", () => {
const parsed = {
kind: "flip.transfer.success",
messageId: "1a0c704164e0cd54",
amount: 40001,
reference: "ST260918145355861M3ZG4",
occurredAt: "2026-09-18T14:53:00+07:00",
details: {
namaTujuan: "Rwen Ibrahim",
bankTujuan: "BCA",
nomorRekening: "0020215810",
},
};
const rec = toSpendingRecord(parsed, flipTransfer);
assert.equal(rec.invoice_id, "ST260918145355861M3ZG4");
assert.equal(rec.amount, 40001);
assert.equal(rec.source, "flip");
assert.equal(rec.description, "Rwen Ibrahim");
assert.equal(rec.trx_date, toPocketBaseDate(parsed.occurredAt));
assert.deepEqual(rec.parsed, parsed);
});
});
describe("parseFlipTransfer", () => {
it("parses direct stacked-label body", () => {
const r = parseFlipTransfer(flipDirectBody);
assert.ok(r);
assert.equal(r.amount, 40001);
assert.equal(r.reference, "ST260918145355861M3ZG4");
assert.equal(r.occurredAt, "2026-09-18T14:53:00+07:00");
assert.equal(r.details.namaTujuan, "Rwen Ibrahim");
assert.equal(r.details.bankTujuan, "BCA");
assert.equal(r.details.nomorRekening, "0020215810");
});
it("parses forwarded body", () => {
const r = parseFlipTransfer(flipForwardedBody);
assert.ok(r);
assert.equal(r.amount, 40001);
assert.equal(r.reference, "ST260918145355861M3ZG4");
assert.equal(r.occurredAt, "2026-09-18T14:53:00+07:00");
assert.equal(r.details.namaTujuan, "Rwen Ibrahim");
});
it("returns null when Nominal or ID Transaksi missing", () => {
assert.equal(parseFlipTransfer("BUKTI TRANSFER\nNominal\nRp10.000\n"), null);
assert.equal(
parseFlipTransfer("ID Transaksi\n#ABC\nNama Tujuan\nX\n"),
null,
);
});
}); });
describe("parseTokopediaOrder", () => { describe("parseTokopediaOrder", () => {
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
isWatcherMailboxAllowed,
parseWatcherMailboxes,
} from "../src/config.js";
describe("parseWatcherMailboxes", () => {
it("returns empty set for blank input", () => {
assert.equal(parseWatcherMailboxes("").size, 0);
assert.equal(parseWatcherMailboxes(null).size, 0);
assert.equal(parseWatcherMailboxes(" , ").size, 0);
});
it("normalizes and splits emails", () => {
const set = parseWatcherMailboxes(
" Eleven16th@gmail.com , other@Example.COM ",
);
assert.deepEqual([...set].sort(), [
"eleven16th@gmail.com",
"other@example.com",
]);
});
});
describe("isWatcherMailboxAllowed", () => {
it("allows all when allowlist is empty", () => {
const empty = new Set();
assert.equal(isWatcherMailboxAllowed("anyone@x.com", empty), true);
assert.equal(isWatcherMailboxAllowed(null, empty), true);
});
it("requires membership when allowlist is set", () => {
const allow = parseWatcherMailboxes("eleven16th@gmail.com");
assert.equal(
isWatcherMailboxAllowed("eleven16th@gmail.com", allow),
true,
);
assert.equal(
isWatcherMailboxAllowed("Eleven16th@Gmail.com", allow),
true,
);
assert.equal(isWatcherMailboxAllowed("other@gmail.com", allow), false);
assert.equal(isWatcherMailboxAllowed(null, allow), false);
});
});
+3 -2
View File
@@ -17,8 +17,9 @@ export default function Login() {
<div className="card-body gap-4"> <div className="card-body gap-4">
<h1 className="card-title text-2xl">Gmail Reader</h1> <h1 className="card-title text-2xl">Gmail Reader</h1>
<p className="text-sm opacity-70"> <p className="text-sm opacity-70">
Sign in with Google to list, search, and read mail. Access is Sign in with Google to list, search, and read mail in this UI. The
read-only. UI is read-only; the CLI can archive, label, and create drafts
after you grant <code>gmail.modify</code>.
</p> </p>
{error ? ( {error ? (
<div role="alert" className="alert alert-error text-sm"> <div role="alert" className="alert alert-error text-sm">
+130
View File
@@ -0,0 +1,130 @@
# Development — e-receipt pipeline
Watcher + parsers that turn matched receipt mail into PocketBase `spendings` rows.
## Designated mailbox (`WATCHER_MAILBOXES`)
Receipt **watch + Pub/Sub processing** can be limited to specific connected accounts:
```bash
WATCHER_MAILBOXES=eleven16th@gmail.com
# or comma-separated: a@x.com,b@y.com
```
- **Set:** only those mailboxes get `users.watch` and receipt match/parse/save.
- **Unset / empty:** all connected accounts (previous behavior).
- **CLI** (`npm run gmail`, `process-messages`) is **not** gated — other signed-in accounts remain usable for list/archive/draft.
Restart the watcher after changing the env.
## Watcher pipeline
`POST /pubsub/gmail` → `history.list` (`messageAdded`) → per message:
1. `logEvent` + `fetchAndLogMessage` — fetch outer mail; collect `emlMessages` if any
2. **Work items:** if `.eml` / `message/rfc822` attachments exist → process **each attachment only** (outer is a wrapper). If none → process the outer message as usual.
3. For each work item (sequential, in-process — no job queue): `matchRule` → `parseMatchedMessage` → `saveSpending`
Handlers return `{ pass: {...} }` to merge into ctx or `{ break: true }` to stop. **Parse full `message.body`** (and `message.html` when needed), never the 500-char log `bodyPreview`.
Attached `.eml` mails use nested From/Subject from Gmail’s expanded `message/rfc822` part. When nested HTML/text is not inlined (`body.data` missing, only `attachmentId`), the watcher fetches those attachments (`hydrateRfc822Message`) before parse. Synthetic `message_id` looks like `outerId#partId`; dedupe remains on `invoice_id`.
## Match rules
Resolve original sender:
1. If body has `---------- Forwarded message ---------`, use its From / Subject / Date.
2. Else use Gmail envelope.
| kind | From address | Subject |
|---|---|---|
| `livin.qr_payment.success` | `noreply.livin@bankmandiri.co.id` | `Pembayaran Berhasil!` |
| `grab.ereceipt.ride` | `no-reply@grab.com` | `Your Grab E-Receipt` |
| `grab.ereceipt.tip` | same From/Subject as ride; detected from tip body copy | tip invoice_id = `{Booking ID}:tip` |
| `grab.ereceipt.food` | same From/Subject; body has GrabFood / `Selamat menikmati makanan` | `Pesanan ID` as invoice_id |
| `bri.transfer.success` | `bankbri@bri.co.id` | `Pemindahan Dana Sesama Rekening BRI` |
| `bri.qris.success` | `bankbri@bri.co.id` | `Pembelian QRIS Berhasil` |
| `tokopedia.order.completed` | `noreply@tokopedia.com` | `Pesanan Selesai` |
| `flip.transfer.success` | `no-reply@flip.id` | `Transfer ke` (recipient name varies) |
Production path is **direct provider mail**. Forwards still work via the Fwd header. Do **not** match on the forwarder envelope From.
Rules: `apps/watcher/src/rules/`. Parsers: `apps/watcher/src/parsers/`.
## Parsing notes
- **Required for a spending row:** `amount` + `reference` (invoice id). Fail soft (log + break) if missing — never throw (Pub/Sub must ack).
- **Livin:** support clean forwarded lines and jammed HTML-stripped labels (`Tanggal13 Sep 2026`, `Tanggal15 Agu 2026`). Indonesian month names/abbreviations (`Agu`/`Agustus`, `Des`, …) are accepted. When merchant and city are glued on one line, keep the whole string as `merchant`; only split location when it is already a separate line ending in `- ID`. Do not maintain a city-name list.
- **Grab:** plain text often has date-only `Picked up on …`; pickup/dropoff times (`10:47AM`) live in HTML. Watcher keeps `message.html` and merges the first standalone AM/PM time into `occurredAt` (WIB). Tip receipts (`Your tip goes a long way…`, timestamp like `13 Sep 26 10:47 +0700`) become `grab.ereceipt.tip` with `invoice_id` `{bookingId}:tip` so they do not collide with the ride.
- **BRI / BRImo:** transfer (`Pemindahan Dana Sesama Rekening BRI`) — amount from `Nominal`, `invoice_id` from `Nomor Referensi`, `description` from `Nama Tujuan`, datetime from `Tanggal`. QRIS (`Pembelian QRIS Berhasil`) — amount from `Nominal` (fallback `Total Transaksi`), `description` from `Nama Merchant`, datetime from `Tanggal Transaksi`. Other BRI subjects stay out of scope until samples appear.
- **Tokopedia:** `Pesanan Selesai` — one row per `No.Invoice`; amount from `Total belanja` (not fee lines); `description` = `{Toko} - Tokopedia`; line items (1+) in `details.items`; `trx_date` from `Tanggal Terima` (date-only).
- **Flip:** transfer success (`Transfer ke {Name} berhasil`) — amount from `Nominal`, `invoice_id` from `ID Transaksi` (strip leading `#`), `description` from `Nama Tujuan`, datetime from `Waktu Terkirim`. Labels and values are stacked (value on the next line). Direct Flip mail and manual Fwd both match via `resolveOriginal`.
- Open-ended text (names, products, driver, compliments) is best-effort optional `details`.
## PocketBase
Env: `POCKETBASE_URL`, `POCKETBASE_USER`, `POCKETBASE_PASSWORD` (a `_superusers` account). Collection: `spendings`.
- Unique `invoice_id` — duplicate insert → log skip, no throw.
- Leave `category` empty; user fills it in PocketBase admin.
- Store flat columns for querying; `details` for kind leftovers; `parsed` for the full watcher envelope JSON (audit snapshot). Keep `message_id` so the original Gmail message can be re-fetched if needed — do not store raw email body by default.
- Schema ensure runs on watcher boot (`ensureSpendingsSchema`).
- Never commit secrets.
Parse failures and non-duplicate skips (incomplete record, save errors) POST to ntfy (`NTFY_URL`, default `https://n.0dev.web.id/system`) with `messageId` and message body. Duplicates do not notify.
## Manual / backfill (forwarded old receipts)
Gmail Date on a forward is “today”; `trx_date` still comes from the receipt body. Duplicates skip on unique `invoice_id`. Forward-as-attachment (multiple `*.eml`) is expanded the same way as the watcher.
```bash
npm run list-messages --
npm run list-messages -- --query 'newer_than:14d has:attachment filename:eml' --max 100
npm run process-messages -- --dry-run
npm run process-messages -- --ids OUTER_MESSAGE_ID
npm run process-messages -- --query 'newer_than:2d subject:"Pembayaran Berhasil!"'
npm run process-messages -- --ids MESSAGE_ID_1,MESSAGE_ID_2
npm run process-messages -- --user eleven16th@gmail.com --max 100
```
## Adding a provider
1. Rule module under `src/rules/` (kind, fromAddress, subject, source).
2. Parser under `src/parsers/`; register in `parse-matched-message.js`.
3. Fixture + tests under `test/`.
4. Map into spendings in `pocketbase/map.js` (`source`, `description`, `details`, `parsed`).
## Change Google API permissions (`gmail.modify`)
Archive, labels, and drafts need `https://www.googleapis.com/auth/gmail.modify` (superset of read + watch). Keep the same OAuth Client ID / secret — do **not** create a new client.
### Code
`apps/api/src/config.js` uses `GMAIL_SCOPE = gmail.modify` (plus openid / email / profile). Auth already uses `prompt: consent` and `access_type: offline`.
### Google Cloud Console (one-time)
1. Open [Google Cloud Console](https://console.cloud.google.com/) → same project as the OAuth client.
2. **APIs & Services → Library**: confirm **Gmail API** is enabled. Pub/Sub unchanged; `users.watch` still works.
3. **OAuth consent screen** (or **Google Auth Platform → Data Access**):
- Add scope `https://www.googleapis.com/auth/gmail.modify`.
- Remove `gmail.readonly` if listed (modify already includes read).
- Save.
4. **Audience / Test users:** `gmail.modify` is **sensitive**. While **External + Testing**, every mailbox that signs in must be a **Test user**. Skip Google verification unless you publish to Production.
5. Do **not** change Authorized JavaScript origins / redirect URIs unless they are wrong.
If consent fails or the new permission never appears, the scope is missing from the consent screen. An “unverified app” warning is expected in Testing.
### Re-consent every connected mailbox
Stored tokens were issued for `gmail.readonly` and cannot be upgraded in place:
1. Restart the API (`npm run dev`).
2. Sign in **once per Gmail account** via the web UI (`/auth/google`).
3. On the consent screen, accept Google’s wording for modify (includes send/delete). The app still never sends or deletes.
4. Confirm: `npm run gmail -- accounts`. `403 insufficientPermissions` on archive/label/draft means that mailbox has not re-consented.
### Unchanged
Same client secrets, Pub/Sub topic / push URL, and INBOX watch. Web inbox stays read-only; only the CLI mutates.
+108
View File
@@ -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 <email|id>`
2. `GMAIL_DEFAULT_USER`
3. `users.is_default`
4. Sole connected account
5. Else error — set a default
## Commands
```bash
npm run gmail -- <command> …
```
| Command | Purpose | `--reason` |
|---|---|---|
| `accounts` | List mailboxes / set `--default` | no |
| `list` | Search → id / from / subject | no |
| `read <id>` | Full headers + body | no |
| `summarize` | Compact digests for one or many (`--ids` or `--query`) | no |
| `labels` | List label ids/names | no |
| `label <ids> --add/--remove` | Modify labels | **required** |
| `archive <ids>` | Remove `INBOX` | **required** |
| `draft --to --subject --body` | Create a draft (**never sends**) | **required** |
| `forward <id> --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.
+1
View File
@@ -13,6 +13,7 @@
"test:watcher": "npm run test -w watcher", "test:watcher": "npm run test -w watcher",
"process-messages": "npm run process-messages -w watcher --", "process-messages": "npm run process-messages -w watcher --",
"list-messages": "npm run list-messages -w watcher --", "list-messages": "npm run list-messages -w watcher --",
"gmail": "npm run gmail -w watcher --",
"build": "npm run build -w web", "build": "npm run build -w web",
"start": "npm run start -w api", "start": "npm run start -w api",
"start:watcher": "npm run start -w watcher" "start:watcher": "npm run start -w watcher"