feat(watcher): gate receipt watch with WATCHER_MAILBOXES
Limit Pub/Sub INBOX watch and receipt processing to an allowlisted mailbox set; leave the agent CLI usable for all connected accounts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+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=
|
||||||
|
|||||||
@@ -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(
|
||||||
/\/$/,
|
/\/$/,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user