/** * Gmail Watcher Service * * Automatically starts `gog gmail watch serve` when the gateway starts, * if hooks.gmail is configured with an account. */ import { spawn, type ChildProcess } from "node:child_process"; import { hasBinary } from "../agents/skills.js"; import type { ClawdisConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { buildGogWatchServeArgs, buildGogWatchStartArgs, resolveGmailHookRuntimeConfig, type GmailHookRuntimeConfig, } from "./gmail.js"; import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js"; const log = createSubsystemLogger("gmail-watcher"); let watcherProcess: ChildProcess | null = null; let renewInterval: ReturnType | null = null; let restartTimer: ReturnType | null = null; let shuttingDown = false; let currentConfig: GmailHookRuntimeConfig | null = null; const redactedValue = ""; const redactedFlags = new Set(["--token", "--hook-token", "--hook-url"]); function redactArgs(args: string[]): string[] { const redacted = [...args]; for (let i = 0; i < redacted.length; i += 1) { if (redactedFlags.has(redacted[i] ?? "") && redacted[i + 1]) { redacted[i + 1] = redactedValue; i += 1; } } return redacted; } function scheduleRestart(reason: string) { if (shuttingDown || !currentConfig || restartTimer) return; log.warn(`${reason}; restarting in 5s`); restartTimer = setTimeout(() => { restartTimer = null; if (shuttingDown || !currentConfig) return; watcherProcess = spawnGogServe(currentConfig); }, 5000); } /** * Check if gog binary is available */ function isGogAvailable(): boolean { return hasBinary("gog"); } /** * Start the Gmail watch (registers with Gmail API) */ async function startGmailWatch( cfg: Pick, ): Promise { const args = ["gog", ...buildGogWatchStartArgs(cfg)]; try { const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 }); if (result.code !== 0) { const message = result.stderr || result.stdout || "gog watch start failed"; log.error(`watch start failed: ${message}`); return false; } log.info(`watch started for ${cfg.account}`); return true; } catch (err) { log.error(`watch start error: ${String(err)}`); return false; } } /** * Spawn the gog gmail watch serve process */ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess { const args = buildGogWatchServeArgs(cfg); if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; } log.info(`starting gog ${redactArgs(args).join(" ")}`); const child = spawn("gog", args, { stdio: ["ignore", "pipe", "pipe"], detached: false, }); child.stdout?.on("data", (data: Buffer) => { const line = data.toString().trim(); if (line) log.info(`[gog] ${line}`); }); child.stderr?.on("data", (data: Buffer) => { const line = data.toString().trim(); if (line) log.warn(`[gog] ${line}`); }); child.on("error", (err) => { log.error(`gog process error: ${String(err)}`); if (watcherProcess === child) { watcherProcess = null; } scheduleRestart("gog process error"); }); child.on("exit", (code, signal) => { if (shuttingDown) return; if (watcherProcess === child) { watcherProcess = null; } scheduleRestart(`gog exited (code=${code}, signal=${signal})`); }); return child; } export type GmailWatcherStartResult = { started: boolean; reason?: string; }; /** * Start the Gmail watcher service. * Called automatically by the gateway if hooks.gmail is configured. */ export async function startGmailWatcher( cfg: ClawdisConfig, ): Promise { // Check if gmail hooks are configured if (!cfg.hooks?.enabled) { return { started: false, reason: "hooks not enabled" }; } if (!cfg.hooks?.gmail?.account) { return { started: false, reason: "no gmail account configured" }; } // Check if gog is available const gogAvailable = isGogAvailable(); if (!gogAvailable) { return { started: false, reason: "gog binary not found" }; } // Resolve the full runtime config const resolved = resolveGmailHookRuntimeConfig(cfg, {}); if (!resolved.ok) { return { started: false, reason: resolved.error }; } const runtimeConfig = resolved.value; if (isGmailWatcherRunning()) { log.info("gmail watcher already running; skipping start"); return { started: true }; } if (renewInterval) { clearInterval(renewInterval); renewInterval = null; } if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; } if (watcherProcess) { watcherProcess = null; } currentConfig = runtimeConfig; // Set up Tailscale endpoint if needed if (runtimeConfig.tailscale.mode !== "off") { try { await ensureTailscaleEndpoint({ mode: runtimeConfig.tailscale.mode, path: runtimeConfig.tailscale.path, port: runtimeConfig.serve.port, }); log.info( `tailscale ${runtimeConfig.tailscale.mode} configured for port ${runtimeConfig.serve.port}`, ); } catch (err) { log.error(`tailscale setup failed: ${String(err)}`); return { started: false, reason: `tailscale setup failed: ${String(err)}` }; } } // Start the Gmail watch (register with Gmail API) const watchStarted = await startGmailWatch(runtimeConfig); if (!watchStarted) { log.warn("gmail watch start failed, but continuing with serve"); } // Spawn the gog serve process shuttingDown = false; watcherProcess = spawnGogServe(runtimeConfig); // Set up renewal interval const renewMs = runtimeConfig.renewEveryMinutes * 60_000; renewInterval = setInterval(() => { if (shuttingDown) return; void startGmailWatch(runtimeConfig); }, renewMs); log.info( `gmail watcher started for ${runtimeConfig.account} (renew every ${runtimeConfig.renewEveryMinutes}m)`, ); return { started: true }; } /** * Stop the Gmail watcher service. */ export async function stopGmailWatcher(): Promise { shuttingDown = true; if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; } if (renewInterval) { clearInterval(renewInterval); renewInterval = null; } if (watcherProcess) { log.info("stopping gmail watcher"); watcherProcess.kill("SIGTERM"); // Wait a bit for graceful shutdown await new Promise((resolve) => { const timeout = setTimeout(() => { if (watcherProcess) { watcherProcess.kill("SIGKILL"); } resolve(); }, 3000); watcherProcess?.on("exit", () => { clearTimeout(timeout); resolve(); }); }); watcherProcess = null; } currentConfig = null; log.info("gmail watcher stopped"); } /** * Check if the Gmail watcher is running. */ export function isGmailWatcherRunning(): boolean { return ( watcherProcess !== null && !shuttingDown && watcherProcess.exitCode === null ); }