// Gmail hook helpers manage Gmail OAuth setup and watcher launch state. import { randomBytes } from "node:crypto"; import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; import { type OpenClawConfig, DEFAULT_GATEWAY_PORT, type HooksGmailTailscaleMode, resolveGatewayPort, } from "../config/config.js"; import { resolveExecutable } from "../infra/executable-path.js"; import { buildWindowsCmdExeCommandLine, isWindowsBatchCommand, resolveTrustedWindowsCmdExe, } from "../process/windows-command.js"; export const DEFAULT_GMAIL_LABEL = "INBOX"; export const DEFAULT_GMAIL_TOPIC = "gog-gmail-watch"; export const DEFAULT_GMAIL_SUBSCRIPTION = "gog-gmail-watch-push"; export const DEFAULT_GMAIL_SERVE_BIND = "127.0.0.1"; export const DEFAULT_GMAIL_SERVE_PORT = 8788; export const DEFAULT_GMAIL_SERVE_PATH = "/gmail-pubsub"; export const DEFAULT_GMAIL_MAX_BYTES = 20_000; export const DEFAULT_GMAIL_RENEW_MINUTES = 12 * 60; const DEFAULT_HOOKS_PATH = "/hooks"; // OpenClaw handles inbound mail; override gog's narrower SPAM,TRASH default. const GMAIL_WATCH_EXCLUDED_LABELS = "SPAM,TRASH,DRAFT,SENT"; const GMAIL_WATCH_SENSITIVE_FLAGS = new Set(["--token", "--hook-url", "--hook-token"]); let gogBin: string | undefined; export type GmailHookOverrides = { account?: string; label?: string; topic?: string; subscription?: string; pushToken?: string; hookToken?: string; hookUrl?: string; includeBody?: boolean; maxBytes?: number; renewEveryMinutes?: number; serveBind?: string; servePort?: number; servePath?: string; tailscaleMode?: HooksGmailTailscaleMode; tailscalePath?: string; tailscaleTarget?: string; }; export type GmailHookRuntimeConfig = { account: string; label: string; topic: string; subscription: string; pushToken: string; hookToken: string; hookUrl: string; includeBody: boolean; maxBytes: number; renewEveryMinutes: number; serve: { bind: string; port: number; path: string; }; tailscale: { mode: HooksGmailTailscaleMode; path: string; target?: string; }; }; export function generateHookToken(bytes = 24): string { return randomBytes(bytes).toString("hex"); } /** Resolve the per-message body byte bound gog is provisioned with (`--max-bytes`). */ export function resolveGmailHookMaxBytes(raw: number | undefined): number { return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_GMAIL_MAX_BYTES; } export function mergeHookPresets(existing: string[] | undefined, preset: string): string[] { const next = new Set(normalizeUniqueStringEntries(existing)); next.add(preset); return Array.from(next); } export function normalizeHooksPath(raw?: string): string { const base = raw?.trim() || DEFAULT_HOOKS_PATH; if (base === "/") { return DEFAULT_HOOKS_PATH; } const withSlash = base.startsWith("/") ? base : `/${base}`; return withSlash.replace(/\/+$/, ""); } export function normalizeServePath(raw?: string): string { const base = raw?.trim() || DEFAULT_GMAIL_SERVE_PATH; // Tailscale funnel/serve strips the set-path prefix before proxying. // To accept requests at / externally, gog must listen on "/". if (base === "/") { return "/"; } const withSlash = base.startsWith("/") ? base : `/${base}`; return withSlash.replace(/\/+$/, ""); } export function buildDefaultHookUrl( hooksPath?: string, port: number = DEFAULT_GATEWAY_PORT, ): string { const basePath = normalizeHooksPath(hooksPath); const baseUrl = `http://127.0.0.1:${port}`; return joinUrl(baseUrl, `${basePath}/gmail`); } export function resolveGmailHookRuntimeConfig( cfg: OpenClawConfig, overrides: GmailHookOverrides, ): { ok: true; value: GmailHookRuntimeConfig } | { ok: false; error: string } { const hooks = cfg.hooks; const gmail = hooks?.gmail; const hookToken = overrides.hookToken ?? hooks?.token ?? ""; if (!hookToken) { return { ok: false, error: "hooks.token missing (needed for gmail hook)" }; } const account = overrides.account ?? gmail?.account ?? ""; if (!account) { return { ok: false, error: "gmail account required" }; } const topic = overrides.topic ?? gmail?.topic ?? ""; if (!topic) { return { ok: false, error: "gmail topic required" }; } const subscription = overrides.subscription ?? gmail?.subscription ?? DEFAULT_GMAIL_SUBSCRIPTION; const pushToken = overrides.pushToken ?? gmail?.pushToken ?? ""; if (!pushToken) { return { ok: false, error: "gmail push token required" }; } const hookUrl = overrides.hookUrl ?? gmail?.hookUrl ?? buildDefaultHookUrl(hooks?.path, resolveGatewayPort(cfg)); const includeBody = overrides.includeBody ?? gmail?.includeBody ?? true; const maxBytes = resolveGmailHookMaxBytes(overrides.maxBytes ?? gmail?.maxBytes); const renewEveryMinutesRaw = overrides.renewEveryMinutes ?? gmail?.renewEveryMinutes; const renewEveryMinutes = typeof renewEveryMinutesRaw === "number" && Number.isFinite(renewEveryMinutesRaw) && renewEveryMinutesRaw > 0 ? Math.floor(renewEveryMinutesRaw) : DEFAULT_GMAIL_RENEW_MINUTES; const serveBind = overrides.serveBind ?? gmail?.serve?.bind ?? DEFAULT_GMAIL_SERVE_BIND; const servePortRaw = overrides.servePort ?? gmail?.serve?.port; const servePort = typeof servePortRaw === "number" && Number.isFinite(servePortRaw) && servePortRaw > 0 ? Math.floor(servePortRaw) : DEFAULT_GMAIL_SERVE_PORT; const servePathRaw = overrides.servePath ?? gmail?.serve?.path; const normalizedServePathRaw = typeof servePathRaw === "string" && servePathRaw.trim().length > 0 ? normalizeServePath(servePathRaw) : DEFAULT_GMAIL_SERVE_PATH; const tailscaleTargetRaw = overrides.tailscaleTarget ?? gmail?.tailscale?.target; const tailscaleMode = overrides.tailscaleMode ?? gmail?.tailscale?.mode ?? "off"; const tailscaleTarget = tailscaleMode !== "off" && typeof tailscaleTargetRaw === "string" && tailscaleTargetRaw.trim().length > 0 ? tailscaleTargetRaw.trim() : undefined; // Tailscale strips the public path before proxying, so listen on "/" when on. const servePath = normalizeServePath( tailscaleMode !== "off" && !tailscaleTarget ? "/" : normalizedServePathRaw, ); const tailscalePathRaw = overrides.tailscalePath ?? gmail?.tailscale?.path; const tailscalePath = normalizeServePath( tailscaleMode !== "off" ? (tailscalePathRaw ?? normalizedServePathRaw) : (tailscalePathRaw ?? servePath), ); return { ok: true, value: { account, label: overrides.label ?? gmail?.label ?? DEFAULT_GMAIL_LABEL, topic, subscription, pushToken, hookToken, hookUrl, includeBody, maxBytes, renewEveryMinutes, serve: { bind: serveBind, port: servePort, path: servePath, }, tailscale: { mode: tailscaleMode, path: tailscalePath, target: tailscaleTarget, }, }, }; } export function buildGogWatchStartArgs( cfg: Pick, ): string[] { return [ "gmail", "watch", "start", "--account", cfg.account, "--label", cfg.label, "--topic", cfg.topic, ]; } export function buildGogWatchServeArgs(cfg: GmailHookRuntimeConfig): string[] { const args = [ "gmail", "watch", "serve", "--account", cfg.account, "--bind", cfg.serve.bind, "--port", String(cfg.serve.port), "--path", cfg.serve.path, "--token", cfg.pushToken, "--hook-url", cfg.hookUrl, "--hook-token", cfg.hookToken, ]; if (cfg.includeBody) { args.push("--include-body"); } args.push("--exclude-labels", GMAIL_WATCH_EXCLUDED_LABELS); if (cfg.maxBytes > 0) { args.push("--max-bytes", String(cfg.maxBytes)); } return args; } export function buildGogWatchServeLogArgs(cfg: GmailHookRuntimeConfig): string[] { return buildGogWatchServeArgs(cfg).filter( (arg, index, args) => !GMAIL_WATCH_SENSITIVE_FLAGS.has(arg) && !GMAIL_WATCH_SENSITIVE_FLAGS.has(args[index - 1] ?? ""), ); } export function resolveGogExecutable(): string { return (gogBin ??= resolveExecutable("gog")); } export function resolveGogServeInvocation(args: string[]): { args: string[]; command: string; windowsHide?: true; windowsVerbatimArguments?: true; } { const command = resolveGogExecutable(); if (!isWindowsBatchCommand(command)) { return { command, args, windowsHide: process.platform === "win32" ? true : undefined }; } return { command: resolveTrustedWindowsCmdExe(), args: ["/d", "/s", "/c", buildWindowsCmdExeCommandLine(command, args)], windowsHide: true, windowsVerbatimArguments: true, }; } export function buildTopicPath(projectId: string, topicName: string): string { return `projects/${projectId}/topics/${topicName}`; } export function parseTopicPath(topic: string): { projectId: string; topicName: string } | null { const match = topic.trim().match(/^projects\/([^/]+)\/topics\/([^/]+)$/i); if (!match) { return null; } return { projectId: match[1] ?? "", topicName: match[2] ?? "" }; } function joinUrl(base: string, pathLocal: string): string { const url = new URL(base); const basePath = url.pathname.replace(/\/+$/, ""); const extra = pathLocal.startsWith("/") ? pathLocal : `/${pathLocal}`; url.pathname = `${basePath}${extra}`; return url.toString(); }