Match no-reply@flip.id transfer success mail (direct or forwarded), parse stacked Nominal/ID Transaksi fields, and map spendings with source=flip. Co-authored-by: Cursor <cursoragent@cursor.com>
8.3 KiB
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:
WATCHER_MAILBOXES=eleven16th@gmail.com
# or comma-separated: a@x.com,b@y.com
- Set: only those mailboxes get
users.watchand 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:
logEvent+fetchAndLogMessage— fetch outer mail; collectemlMessagesif any- Work items: if
.eml/message/rfc822attachments exist → process each attachment only (outer is a wrapper). If none → process the outer message as usual. - 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:
- If body has
---------- Forwarded message ---------, use its From / Subject / Date. - 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 asmerchant; 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 keepsmessage.htmland merges the first standalone AM/PM time intooccurredAt(WIB). Tip receipts (Your tip goes a long way…, timestamp like13 Sep 26 10:47 +0700) becomegrab.ereceipt.tipwithinvoice_id{bookingId}:tipso they do not collide with the ride. - BRI / BRImo: transfer (
Pemindahan Dana Sesama Rekening BRI) — amount fromNominal,invoice_idfromNomor Referensi,descriptionfromNama Tujuan, datetime fromTanggal. QRIS (Pembelian QRIS Berhasil) — amount fromNominal(fallbackTotal Transaksi),descriptionfromNama Merchant, datetime fromTanggal Transaksi. Other BRI subjects stay out of scope until samples appear. - Tokopedia:
Pesanan Selesai— one row perNo.Invoice; amount fromTotal belanja(not fee lines);description={Toko} - Tokopedia; line items (1+) indetails.items;trx_datefromTanggal Terima(date-only). - Flip: transfer success (
Transfer ke {Name} berhasil) — amount fromNominal,invoice_idfromID Transaksi(strip leading#),descriptionfromNama Tujuan, datetime fromWaktu Terkirim. Labels and values are stacked (value on the next line). Direct Flip mail and manual Fwd both match viaresolveOriginal. - 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
categoryempty; user fills it in PocketBase admin. - Store flat columns for querying;
detailsfor kind leftovers;parsedfor the full watcher envelope JSON (audit snapshot). Keepmessage_idso 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.
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
- Rule module under
src/rules/(kind, fromAddress, subject, source). - Parser under
src/parsers/; register inparse-matched-message.js. - Fixture + tests under
test/. - 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)
- Open Google Cloud Console → same project as the OAuth client.
- APIs & Services → Library: confirm Gmail API is enabled. Pub/Sub unchanged;
users.watchstill works. - OAuth consent screen (or Google Auth Platform → Data Access):
- Add scope
https://www.googleapis.com/auth/gmail.modify. - Remove
gmail.readonlyif listed (modify already includes read). - Save.
- Add scope
- Audience / Test users:
gmail.modifyis sensitive. While External + Testing, every mailbox that signs in must be a Test user. Skip Google verification unless you publish to Production. - 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:
- Restart the API (
npm run dev). - Sign in once per Gmail account via the web UI (
/auth/google). - On the consent screen, accept Google’s wording for modify (includes send/delete). The app still never sends or deletes.
- Confirm:
npm run gmail -- accounts.403 insufficientPermissionson 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.