docs: refresh agent playbooks and project README
Point agents at the CLI safety rules, mailbox defaults, and the split usage/development guides. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user