diff --git a/.env.example b/.env.example index e3c9986..2db1faa 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Google OAuth Web client (gmail.readonly) +# Google OAuth Web client (gmail.modify) GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= @@ -14,8 +14,14 @@ SESSION_SECRET=change-me-to-a-long-random-string PORT=3000 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_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 # A short name like gmail-push is expanded if GOOGLE_CLOUD_PROJECT is set. GOOGLE_PUBSUB_TOPIC= diff --git a/apps/watcher/src/config.js b/apps/watcher/src/config.js index 6ccbf4e..d7e119a 100644 --- a/apps/watcher/src/config.js +++ b/apps/watcher/src/config.js @@ -20,6 +20,36 @@ export const GOOGLE_CLOUD_PROJECT = export const PUBSUB_VERIFICATION_TOKEN = process.env.PUBSUB_VERIFICATION_TOKEN || ""; 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} [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( /\/$/, diff --git a/apps/watcher/src/index.js b/apps/watcher/src/index.js index 048d08f..38d8f2b 100644 --- a/apps/watcher/src/index.js +++ b/apps/watcher/src/index.js @@ -1,5 +1,12 @@ 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 { startWatch } from "./google.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"); return; } + + if (WATCHER_MAILBOXES.size > 0) { + app.log.info( + { allowlist: [...WATCHER_MAILBOXES] }, + "gmail-watch: WATCHER_MAILBOXES restrict receipt watches", + ); + } + 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 { await startWatch(user.id); app.log.info( diff --git a/apps/watcher/src/routes/pubsub.js b/apps/watcher/src/routes/pubsub.js index 41c7afb..649a908 100644 --- a/apps/watcher/src/routes/pubsub.js +++ b/apps/watcher/src/routes/pubsub.js @@ -1,5 +1,5 @@ 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 { gmailClientForUser, @@ -71,6 +71,14 @@ function decodeNotification(body) { } 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); if (!user) { log.warn( diff --git a/apps/watcher/test/watcher-mailboxes.test.js b/apps/watcher/test/watcher-mailboxes.test.js new file mode 100644 index 0000000..2b67c56 --- /dev/null +++ b/apps/watcher/test/watcher-mailboxes.test.js @@ -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); + }); +});