Compare commits
5
Commits
39eb1bce2f
...
ec3b683786
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3b683786 | ||
|
|
427636b59b | ||
|
|
f270f531ea | ||
|
|
770ed8121a | ||
|
|
bac8edfb6c |
+7
-1
@@ -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=
|
||||||
|
|||||||
@@ -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`).
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
#!/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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
/\/$/,
|
/\/$/,
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const flipTransfer = {
|
||||||
|
kind: "flip.transfer.success",
|
||||||
|
source: "flip",
|
||||||
|
fromAddress: "no-reply@flip.id",
|
||||||
|
subject: "Transfer ke",
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exercise audit_log insert/list against an in-memory schema that mirrors
|
||||||
|
* apps/watcher/src/db.js (without opening the live app.db).
|
||||||
|
*/
|
||||||
|
function openAuditDb() {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
google_sub TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
picture TEXT,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
mailbox TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
message_ids TEXT,
|
||||||
|
payload TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("audit_log schema behavior", () => {
|
||||||
|
it("inserts and lists with reason + status", () => {
|
||||||
|
const db = openAuditDb();
|
||||||
|
const { lastInsertRowid: userId } = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (google_sub, email, is_default) VALUES ('sub1', 'a@example.com', 1)`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT INTO audit_log (
|
||||||
|
user_id, mailbox, actor, action, reason, message_ids, payload, status, error
|
||||||
|
) VALUES (?, ?, 'cli', ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
insert.run(
|
||||||
|
userId,
|
||||||
|
"a@example.com",
|
||||||
|
"archive",
|
||||||
|
"newsletter noise",
|
||||||
|
JSON.stringify(["msg1"]),
|
||||||
|
JSON.stringify({ removeLabelIds: ["INBOX"] }),
|
||||||
|
"ok",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
insert.run(
|
||||||
|
userId,
|
||||||
|
"a@example.com",
|
||||||
|
"archive",
|
||||||
|
"preview",
|
||||||
|
JSON.stringify(["msg2"]),
|
||||||
|
JSON.stringify({ dryRun: true }),
|
||||||
|
"dry-run",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM audit_log WHERE action = ? ORDER BY id DESC LIMIT 10`,
|
||||||
|
)
|
||||||
|
.all("archive");
|
||||||
|
assert.equal(rows.length, 2);
|
||||||
|
assert.equal(rows[0].status, "dry-run");
|
||||||
|
assert.equal(rows[1].reason, "newsletter noise");
|
||||||
|
assert.deepEqual(JSON.parse(rows[1].message_ids), ["msg1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setDefault clears other defaults", () => {
|
||||||
|
const db = openAuditDb();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO users (google_sub, email, is_default) VALUES ('s1', 'a@example.com', 1)`,
|
||||||
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO users (google_sub, email, is_default) VALUES ('s2', 'b@example.com', 0)`,
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const clearAndSet = db.transaction((id) => {
|
||||||
|
db.prepare(`UPDATE users SET is_default = 0`).run();
|
||||||
|
db.prepare(`UPDATE users SET is_default = 1 WHERE id = ?`).run(id);
|
||||||
|
});
|
||||||
|
const b = db
|
||||||
|
.prepare(`SELECT id FROM users WHERE email = 'b@example.com'`)
|
||||||
|
.get();
|
||||||
|
clearAndSet(b.id);
|
||||||
|
|
||||||
|
const defaults = db
|
||||||
|
.prepare(`SELECT email FROM users WHERE is_default = 1`)
|
||||||
|
.all();
|
||||||
|
assert.deepEqual(
|
||||||
|
defaults.map((r) => r.email),
|
||||||
|
["b@example.com"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { resolveUser } from "../src/cli/resolve-user.js";
|
||||||
|
import { parseArgv, parseIds, requireReason } from "../src/cli/parse-args.js";
|
||||||
|
import {
|
||||||
|
archiveModifyBody,
|
||||||
|
buildDraftRaw,
|
||||||
|
buildForwardDraftRaw,
|
||||||
|
labelModifyBody,
|
||||||
|
rawToGmailMessage,
|
||||||
|
} from "../src/gmail/draft.js";
|
||||||
|
|
||||||
|
function usersFixture() {
|
||||||
|
return [
|
||||||
|
{ id: 1, email: "a@example.com", is_default: 0 },
|
||||||
|
{ id: 2, email: "b@example.com", is_default: 1 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveUser", () => {
|
||||||
|
it("uses --user email", () => {
|
||||||
|
const users = usersFixture();
|
||||||
|
const result = resolveUser("a@example.com", {
|
||||||
|
listUsers: () => users,
|
||||||
|
getByEmail: (e) => users.find((u) => u.email === e) || null,
|
||||||
|
getById: (id) => users.find((u) => u.id === Number(id)) || null,
|
||||||
|
getDefault: () => users.find((u) => u.is_default) || null,
|
||||||
|
envDefault: "",
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.user.email, "a@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses --user id", () => {
|
||||||
|
const users = usersFixture();
|
||||||
|
const result = resolveUser("2", {
|
||||||
|
listUsers: () => users,
|
||||||
|
getByEmail: () => null,
|
||||||
|
getById: (id) => users.find((u) => u.id === Number(id)) || null,
|
||||||
|
getDefault: () => null,
|
||||||
|
envDefault: "",
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.user.id, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses GMAIL_DEFAULT_USER over is_default", () => {
|
||||||
|
const users = usersFixture();
|
||||||
|
const result = resolveUser(null, {
|
||||||
|
listUsers: () => users,
|
||||||
|
getByEmail: (e) => users.find((u) => u.email === e) || null,
|
||||||
|
getById: () => null,
|
||||||
|
getDefault: () => users.find((u) => u.is_default) || null,
|
||||||
|
envDefault: "a@example.com",
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.user.email, "a@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses is_default when no flag/env", () => {
|
||||||
|
const users = usersFixture();
|
||||||
|
const result = resolveUser(null, {
|
||||||
|
listUsers: () => users,
|
||||||
|
getByEmail: () => null,
|
||||||
|
getById: () => null,
|
||||||
|
getDefault: () => users.find((u) => u.is_default) || null,
|
||||||
|
envDefault: "",
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.user.email, "b@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses sole connected user", () => {
|
||||||
|
const users = [{ id: 9, email: "only@example.com", is_default: 0 }];
|
||||||
|
const result = resolveUser(null, {
|
||||||
|
listUsers: () => users,
|
||||||
|
getByEmail: () => null,
|
||||||
|
getById: () => null,
|
||||||
|
getDefault: () => null,
|
||||||
|
envDefault: "",
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.user.email, "only@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when multiple and no default", () => {
|
||||||
|
const users = [
|
||||||
|
{ id: 1, email: "a@example.com", is_default: 0 },
|
||||||
|
{ id: 2, email: "b@example.com", is_default: 0 },
|
||||||
|
];
|
||||||
|
const result = resolveUser(null, {
|
||||||
|
listUsers: () => users,
|
||||||
|
getByEmail: () => null,
|
||||||
|
getById: () => null,
|
||||||
|
getDefault: () => null,
|
||||||
|
envDefault: "",
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /Multiple accounts/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when no users", () => {
|
||||||
|
const result = resolveUser(null, {
|
||||||
|
listUsers: () => [],
|
||||||
|
getByEmail: () => null,
|
||||||
|
getById: () => null,
|
||||||
|
getDefault: () => null,
|
||||||
|
envDefault: "",
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /No signed-in users/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireReason", () => {
|
||||||
|
it("rejects missing reason", () => {
|
||||||
|
const r = requireReason({ reason: " " }, "archive");
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.match(r.error, /--reason is required/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts non-empty reason", () => {
|
||||||
|
const r = requireReason({ reason: "noise" }, "archive");
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.equal(r.reason, "noise");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseArgv / parseIds", () => {
|
||||||
|
it("parses dry-run and multi ids", () => {
|
||||||
|
const args = parseArgv(
|
||||||
|
["abc", "--dry-run", "--ids", "x,y", "--reason", "why"],
|
||||||
|
{
|
||||||
|
booleans: ["dryRun"],
|
||||||
|
strings: ["reason"],
|
||||||
|
multi: ["ids"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(args.dryRun, true);
|
||||||
|
assert.deepEqual(args.ids, ["x", "y"]);
|
||||||
|
assert.equal(args.reason, "why");
|
||||||
|
assert.deepEqual(parseIds(args), ["x", "y", "abc"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("draft MIME helpers", () => {
|
||||||
|
it("buildDraftRaw includes headers and body", () => {
|
||||||
|
const raw = buildDraftRaw({
|
||||||
|
to: "a@b.com",
|
||||||
|
subject: "Hello",
|
||||||
|
body: "Line1\nLine2",
|
||||||
|
cc: "c@d.com",
|
||||||
|
});
|
||||||
|
assert.match(raw, /^To: a@b.com\r\n/);
|
||||||
|
assert.match(raw, /Cc: c@d.com/);
|
||||||
|
assert.match(raw, /Subject: Hello/);
|
||||||
|
assert.match(raw, /Line1\nLine2$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildForwardDraftRaw prefixes Fwd and quotes original", () => {
|
||||||
|
const raw = buildForwardDraftRaw({
|
||||||
|
to: "acct@example.com",
|
||||||
|
note: "Please book.",
|
||||||
|
original: {
|
||||||
|
from: "grab@example.com",
|
||||||
|
to: "me@example.com",
|
||||||
|
subject: "Your Grab E-Receipt",
|
||||||
|
date: "Mon, 1 Jan 2026",
|
||||||
|
body: "Total Rp10.000",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.match(raw, /Subject: Fwd: Your Grab E-Receipt/);
|
||||||
|
assert.match(raw, /Please book\./);
|
||||||
|
assert.match(raw, /---------- Forwarded message ---------/);
|
||||||
|
assert.match(raw, /From: grab@example.com/);
|
||||||
|
assert.match(raw, /Total Rp10\.000/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double Fwd: prefix", () => {
|
||||||
|
const raw = buildForwardDraftRaw({
|
||||||
|
to: "a@b.com",
|
||||||
|
original: { subject: "Fwd: Already", body: "x" },
|
||||||
|
});
|
||||||
|
assert.match(raw, /Subject: Fwd: Already/);
|
||||||
|
assert.doesNotMatch(raw, /Subject: Fwd: Fwd:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rawToGmailMessage base64url-encodes", () => {
|
||||||
|
const { raw } = rawToGmailMessage("hi+/=?");
|
||||||
|
assert.equal(raw.includes("+"), false);
|
||||||
|
assert.equal(raw.includes("/"), false);
|
||||||
|
assert.ok(raw.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("archiveModifyBody removes INBOX only", () => {
|
||||||
|
assert.deepEqual(archiveModifyBody(), { removeLabelIds: ["INBOX"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labelModifyBody adds and removes", () => {
|
||||||
|
assert.deepEqual(labelModifyBody({ add: ["Label_1"], remove: ["INBOX"] }), {
|
||||||
|
addLabelIds: ["Label_1"],
|
||||||
|
removeLabelIds: ["INBOX"],
|
||||||
|
});
|
||||||
|
assert.deepEqual(labelModifyBody({ add: ["A"] }), {
|
||||||
|
addLabelIds: ["A"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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*
|
||||||
|
`;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user