273 lines
7.4 KiB
TypeScript
273 lines
7.4 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
|
|
import {
|
|
type ClawdisConfig,
|
|
DEFAULT_GATEWAY_PORT,
|
|
type HooksGmailTailscaleMode,
|
|
resolveGatewayPort,
|
|
} from "../config/config.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;
|
|
export const DEFAULT_HOOKS_PATH = "/hooks";
|
|
|
|
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;
|
|
};
|
|
|
|
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;
|
|
};
|
|
};
|
|
|
|
export function generateHookToken(bytes = 24): string {
|
|
return randomBytes(bytes).toString("hex");
|
|
}
|
|
|
|
export function mergeHookPresets(
|
|
existing: string[] | undefined,
|
|
preset: string,
|
|
): string[] {
|
|
const next = new Set(
|
|
(existing ?? []).map((item) => item.trim()).filter(Boolean),
|
|
);
|
|
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 /<path> 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: ClawdisConfig,
|
|
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 maxBytesRaw = overrides.maxBytes ?? gmail?.maxBytes;
|
|
const maxBytes =
|
|
typeof maxBytesRaw === "number" &&
|
|
Number.isFinite(maxBytesRaw) &&
|
|
maxBytesRaw > 0
|
|
? Math.floor(maxBytesRaw)
|
|
: DEFAULT_GMAIL_MAX_BYTES;
|
|
|
|
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 hasExplicitServePath =
|
|
typeof servePathRaw === "string" && servePathRaw.trim().length > 0;
|
|
|
|
const tailscaleMode =
|
|
overrides.tailscaleMode ?? gmail?.tailscale?.mode ?? "off";
|
|
// When exposing the push endpoint via Tailscale, the public path is stripped
|
|
// before proxying; use "/" internally unless the user set a path explicitly.
|
|
const servePath = normalizeServePath(
|
|
tailscaleMode !== "off" && !hasExplicitServePath ? "/" : servePathRaw,
|
|
);
|
|
|
|
const tailscalePathRaw = overrides.tailscalePath ?? gmail?.tailscale?.path;
|
|
const tailscalePath = normalizeServePath(
|
|
tailscaleMode !== "off" && !tailscalePathRaw
|
|
? hasExplicitServePath
|
|
? servePathRaw
|
|
: DEFAULT_GMAIL_SERVE_PATH
|
|
: (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,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildGogWatchStartArgs(
|
|
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
|
|
): 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");
|
|
}
|
|
if (cfg.maxBytes > 0) {
|
|
args.push("--max-bytes", String(cfg.maxBytes));
|
|
}
|
|
return args;
|
|
}
|
|
|
|
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, path: string): string {
|
|
const url = new URL(base);
|
|
const basePath = url.pathname.replace(/\/+$/, "");
|
|
const extra = path.startsWith("/") ? path : `/${path}`;
|
|
url.pathname = `${basePath}${extra}`;
|
|
return url.toString();
|
|
}
|