diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db821a7..805b2b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: node-version: ${{ matrix.node-version }} - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile + - run: bun run lint + - run: bun run format:check - run: bun run typecheck - run: bun run typecheck:e2e - run: bun run test diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..9af2565 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 80 +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..0cd4329 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,6 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "error" + } +} diff --git a/AGENTS.md b/AGENTS.md index 0b58074..2f48c35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Instructions for AI agents working in this repository. - **Runtime:** Bun + TypeScript, ES modules only. - Touch only code related to the task. -- Comment *why*, never *what*. +- Comment _why_, never _what_. - No emojis in code or docs. ## Build & Validation diff --git a/CLAUDE.md b/CLAUDE.md index 764c1dd..8baee3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,3 @@ - Default to using Bun instead of Node.js. - Use `bun ` instead of `node ` or `ts-node ` diff --git a/bun.lock b/bun.lock index aeb4f27..279142f 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,8 @@ "@intx/types": "0.4.0", "@types/bun": "1.3.14", "@types/node": "22.10.5", + "oxfmt": "0.70.0", + "oxlint": "1.85.0", "typescript": "5.9.3", }, "peerDependencies": { @@ -48,6 +50,82 @@ "@logtape/logtape": ["@logtape/logtape@2.3.2", "", {}, "sha512-SfxHfSdDlTp6tLpYP5uezNitSCTQUB3FZ1MXDbgDBN9t9kvF+XL0rOGTIa/pJcDMy1o9FELaZ25E/Ypmsf5fNQ=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.70.0", "", { "os": "android", "cpu": "arm" }, "sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.70.0", "", { "os": "android", "cpu": "arm64" }, "sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.70.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.70.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.70.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.70.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.70.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.70.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.70.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.70.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.70.0", "", { "os": "win32", "cpu": "x64" }, "sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.85.0", "", { "os": "android", "cpu": "arm" }, "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.85.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.85.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.85.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.85.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.85.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.85.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.85.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.85.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.85.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@22.10.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ=="], @@ -148,6 +226,10 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "oxfmt": ["oxfmt@0.70.0", "", { "dependencies": { "tinypool": "2.1.2" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.70.0", "@oxfmt/binding-android-arm64": "0.70.0", "@oxfmt/binding-darwin-arm64": "0.70.0", "@oxfmt/binding-darwin-x64": "0.70.0", "@oxfmt/binding-freebsd-x64": "0.70.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.70.0", "@oxfmt/binding-linux-arm-musleabihf": "0.70.0", "@oxfmt/binding-linux-arm64-gnu": "0.70.0", "@oxfmt/binding-linux-arm64-musl": "0.70.0", "@oxfmt/binding-linux-ppc64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-musl": "0.70.0", "@oxfmt/binding-linux-s390x-gnu": "0.70.0", "@oxfmt/binding-linux-x64-gnu": "0.70.0", "@oxfmt/binding-linux-x64-musl": "0.70.0", "@oxfmt/binding-openharmony-arm64": "0.70.0", "@oxfmt/binding-win32-arm64-msvc": "0.70.0", "@oxfmt/binding-win32-ia32-msvc": "0.70.0", "@oxfmt/binding-win32-x64-msvc": "0.70.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw=="], + + "oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], @@ -172,6 +254,8 @@ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "tinypool": ["tinypool@2.1.2", "", {}, "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww=="], + "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], diff --git a/e2e/gmail.mutations.e2e.test.ts b/e2e/gmail.mutations.e2e.test.ts index 3aadf8c..060b4ee 100644 --- a/e2e/gmail.mutations.e2e.test.ts +++ b/e2e/gmail.mutations.e2e.test.ts @@ -10,7 +10,8 @@ import { const LIVE_TEST_TIMEOUT_MS = 60_000; const mutationEnabled = - process.env.GMAIL_LIVE_TEST === "1" && process.env.GMAIL_LIVE_MUTATION_TEST === "1"; + process.env.GMAIL_LIVE_TEST === "1" && + process.env.GMAIL_LIVE_MUTATION_TEST === "1"; if (!mutationEnabled) { test.skip("Gmail mutations require GMAIL_LIVE_TEST=1 and GMAIL_LIVE_MUTATION_TEST=1", () => {}); @@ -33,112 +34,141 @@ if (!mutationEnabled) { } }); - test("creates and cleans up a draft, then restores fixture labels", async () => { - const signal = new AbortController().signal; - const run = (name: string, arguments_: Record) => - harness.tools.run( - { id: `live-${name}`, name, arguments: arguments_ }, - signal, - ); - const subject = `interchange-gmail-live-${crypto.randomUUID()}`; - draftSubjects.add(subject); - - const draftResult = await run("gmail_create_draft", { - subject, - body: "Standalone Gmail live-test draft.", - }); - const draftId = getString(toolData(draftResult, "gmail_create_draft"), "id"); - if (draftId === undefined) throw new Error("gmail_create_draft did not return an ID"); - - const listDraftsResult = await run("gmail_list_drafts", { - query: `subject:${subject}`, - }); - const drafts = getArray(toolData(listDraftsResult, "gmail_list_drafts"), "drafts") ?? []; - expect( - drafts.some( - (draft) => isRecord(draft) && getString(draft, "id") === draftId, - ), - ).toBe(true); - - const originalMessage = await run("gmail_get_message", { - messageId: harness.fixture.messageId, - messageFormat: "METADATA_ONLY", - }); - const messageWasStarred = (getArray( - toolData(originalMessage, "gmail_get_message"), - "labelIds", - ) ?? []).includes("STARRED"); + test( + "creates and cleans up a draft, then restores fixture labels", + async () => { + const signal = new AbortController().signal; + const run = (name: string, arguments_: Record) => + harness.tools.run( + { id: `live-${name}`, name, arguments: arguments_ }, + signal, + ); + const subject = `interchange-gmail-live-${crypto.randomUUID()}`; + draftSubjects.add(subject); - const originalThread = await run("gmail_get_thread", { - threadId: harness.fixture.threadId, - messageFormat: "METADATA_ONLY", - }); - const originalMessages = getArray( - toolData(originalThread, "gmail_get_thread"), - "messages", - ) ?? []; - const originalMessageData = originalMessages[0]; - const threadWasStarred = - isRecord(originalMessageData) && - (getArray(originalMessageData, "labelIds") ?? []).includes("STARRED"); + const draftResult = await run("gmail_create_draft", { + subject, + body: "Standalone Gmail live-test draft.", + }); + const draftId = getString( + toolData(draftResult, "gmail_create_draft"), + "id", + ); + if (draftId === undefined) + throw new Error("gmail_create_draft did not return an ID"); - try { - const labelMessage = await run("gmail_label_message", { - messageId: harness.fixture.messageId, - labelIds: ["STARRED"], + const listDraftsResult = await run("gmail_list_drafts", { + query: `subject:${subject}`, }); + const drafts = + getArray(toolData(listDraftsResult, "gmail_list_drafts"), "drafts") ?? + []; expect( - (getArray(toolData(labelMessage, "gmail_label_message"), "labelIds") ?? []).includes( - "STARRED", + drafts.some( + (draft) => isRecord(draft) && getString(draft, "id") === draftId, ), ).toBe(true); - const unlabelMessage = await run("gmail_unlabel_message", { + const originalMessage = await run("gmail_get_message", { messageId: harness.fixture.messageId, - labelIds: ["STARRED"], + messageFormat: "METADATA_ONLY", }); - expect( - (getArray(toolData(unlabelMessage, "gmail_unlabel_message"), "labelIds") ?? []).includes( - "STARRED", - ), - ).toBe(false); + const messageWasStarred = ( + getArray( + toolData(originalMessage, "gmail_get_message"), + "labelIds", + ) ?? [] + ).includes("STARRED"); - const labelThread = await run("gmail_label_thread", { + const originalThread = await run("gmail_get_thread", { threadId: harness.fixture.threadId, - labelIds: ["STARRED"], + messageFormat: "METADATA_ONLY", }); - expect( - (getArray(toolData(labelThread, "gmail_label_thread"), "messages") ?? []).every( - (message) => - isRecord(message) && - (getArray(message, "labelIds") ?? []).includes("STARRED"), - ), - ).toBe(true); + const originalMessages = + getArray(toolData(originalThread, "gmail_get_thread"), "messages") ?? + []; + const originalMessageData = originalMessages[0]; + const threadWasStarred = + isRecord(originalMessageData) && + (getArray(originalMessageData, "labelIds") ?? []).includes("STARRED"); - const unlabelThread = await run("gmail_unlabel_thread", { - threadId: harness.fixture.threadId, - labelIds: ["STARRED"], - }); - expect( - (getArray(toolData(unlabelThread, "gmail_unlabel_thread"), "messages") ?? []).every( - (message) => - !isRecord(message) || - !(getArray(message, "labelIds") ?? []).includes("STARRED"), - ), - ).toBe(true); - } finally { try { - await run( - messageWasStarred ? "gmail_label_message" : "gmail_unlabel_message", - { messageId: harness.fixture.messageId, labelIds: ["STARRED"] }, - ); + const labelMessage = await run("gmail_label_message", { + messageId: harness.fixture.messageId, + labelIds: ["STARRED"], + }); + expect( + ( + getArray( + toolData(labelMessage, "gmail_label_message"), + "labelIds", + ) ?? [] + ).includes("STARRED"), + ).toBe(true); + + const unlabelMessage = await run("gmail_unlabel_message", { + messageId: harness.fixture.messageId, + labelIds: ["STARRED"], + }); + expect( + ( + getArray( + toolData(unlabelMessage, "gmail_unlabel_message"), + "labelIds", + ) ?? [] + ).includes("STARRED"), + ).toBe(false); + + const labelThread = await run("gmail_label_thread", { + threadId: harness.fixture.threadId, + labelIds: ["STARRED"], + }); + expect( + ( + getArray( + toolData(labelThread, "gmail_label_thread"), + "messages", + ) ?? [] + ).every( + (message) => + isRecord(message) && + (getArray(message, "labelIds") ?? []).includes("STARRED"), + ), + ).toBe(true); + + const unlabelThread = await run("gmail_unlabel_thread", { + threadId: harness.fixture.threadId, + labelIds: ["STARRED"], + }); + expect( + ( + getArray( + toolData(unlabelThread, "gmail_unlabel_thread"), + "messages", + ) ?? [] + ).every( + (message) => + !isRecord(message) || + !(getArray(message, "labelIds") ?? []).includes("STARRED"), + ), + ).toBe(true); } finally { - await run( - threadWasStarred ? "gmail_label_thread" : "gmail_unlabel_thread", - { threadId: harness.fixture.threadId, labelIds: ["STARRED"] }, - ); + try { + await run( + messageWasStarred + ? "gmail_label_message" + : "gmail_unlabel_message", + { messageId: harness.fixture.messageId, labelIds: ["STARRED"] }, + ); + } finally { + await run( + threadWasStarred ? "gmail_label_thread" : "gmail_unlabel_thread", + { threadId: harness.fixture.threadId, labelIds: ["STARRED"] }, + ); + } } - } - }, LIVE_TEST_TIMEOUT_MS); + }, + LIVE_TEST_TIMEOUT_MS, + ); }); } diff --git a/e2e/gmail.read.e2e.test.ts b/e2e/gmail.read.e2e.test.ts index f70da19..dbe793a 100644 --- a/e2e/gmail.read.e2e.test.ts +++ b/e2e/gmail.read.e2e.test.ts @@ -18,37 +18,45 @@ if (process.env.GMAIL_LIVE_TEST !== "1") { await harness?.dispose(); }); - test("reads the named fixture through every read tool", async () => { - const signal = new AbortController().signal; - const threadResult = await harness.tools.run( - { - id: "live-thread-read", - name: "gmail_get_thread", - arguments: { threadId: harness.fixture.threadId }, - }, - signal, - ); - expect(toolId(threadResult, "gmail_get_thread")).toBe(harness.fixture.threadId); - - const messageResult = await harness.tools.run( - { - id: "live-message-read", - name: "gmail_get_message", - arguments: { - messageId: harness.fixture.messageId, - messageFormat: "METADATA_ONLY", + test( + "reads the named fixture through every read tool", + async () => { + const signal = new AbortController().signal; + const threadResult = await harness.tools.run( + { + id: "live-thread-read", + name: "gmail_get_thread", + arguments: { threadId: harness.fixture.threadId }, }, - }, - signal, - ); - expect(toolId(messageResult, "gmail_get_message")).toBe(harness.fixture.messageId); - - const labelsResult = await harness.tools.run( - { id: "live-label-list", name: "gmail_list_labels", arguments: {} }, - signal, - ); - const labels = toolData(labelsResult, "gmail_list_labels").labels; - expect(Array.isArray(labels)).toBe(true); - }, LIVE_TEST_TIMEOUT_MS); + signal, + ); + expect(toolId(threadResult, "gmail_get_thread")).toBe( + harness.fixture.threadId, + ); + + const messageResult = await harness.tools.run( + { + id: "live-message-read", + name: "gmail_get_message", + arguments: { + messageId: harness.fixture.messageId, + messageFormat: "METADATA_ONLY", + }, + }, + signal, + ); + expect(toolId(messageResult, "gmail_get_message")).toBe( + harness.fixture.messageId, + ); + + const labelsResult = await harness.tools.run( + { id: "live-label-list", name: "gmail_list_labels", arguments: {} }, + signal, + ); + const labels = toolData(labelsResult, "gmail_list_labels").labels; + expect(Array.isArray(labels)).toBe(true); + }, + LIVE_TEST_TIMEOUT_MS, + ); }); } diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 2824977..8bdce0e 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -6,7 +6,10 @@ import type { HttpMediatedCredential } from "@intx/types"; import { createRuntimeCapabilities } from "@intx/types/runtime-capabilities"; import type { GmailFetch } from "../src/client/index.js"; -import { createGmailTools, type GmailTools } from "../src/tools/create-tools.js"; +import { + createGmailTools, + type GmailTools, +} from "../src/tools/create-tools.js"; export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -68,11 +71,16 @@ function parseStoredGmailToken(value: unknown): StoredGmailToken { } const grantedScopes = getArray(value, "grantedScopes"); const scopes = - grantedScopes?.filter((scope): scope is string => typeof scope === "string") ?? []; + grantedScopes?.filter( + (scope): scope is string => typeof scope === "string", + ) ?? []; if (grantedScopes !== undefined && scopes.length !== grantedScopes.length) { throw new Error("grantedScopes must contain strings"); } - return { refreshToken, ...(grantedScopes === undefined ? {} : { grantedScopes: scopes }) }; + return { + refreshToken, + ...(grantedScopes === undefined ? {} : { grantedScopes: scopes }), + }; } function isMissingFileError(error: unknown): boolean { @@ -113,15 +121,20 @@ function readGmailOAuthConfig( const clientId = env.GMAIL_LIVE_CLIENT_ID?.trim(); const clientSecret = env.GMAIL_LIVE_CLIENT_SECRET?.trim(); if (clientId === undefined || clientId.length === 0) { - throw new Error("GMAIL_LIVE_CLIENT_ID is required for the live Gmail suite"); + throw new Error( + "GMAIL_LIVE_CLIENT_ID is required for the live Gmail suite", + ); } if (clientSecret === undefined || clientSecret.length === 0) { - throw new Error("GMAIL_LIVE_CLIENT_SECRET is required for the live Gmail suite"); + throw new Error( + "GMAIL_LIVE_CLIENT_SECRET is required for the live Gmail suite", + ); } return { clientId, clientSecret, - tokenFile: env.GMAIL_LIVE_TOKEN_FILE?.trim() || ".local/gmail-live-token.json", + tokenFile: + env.GMAIL_LIVE_TOKEN_FILE?.trim() || ".local/gmail-live-token.json", port: parseLivePort(env.GMAIL_LIVE_PORT), scopes: GMAIL_LIVE_SCOPES, }; @@ -131,12 +144,16 @@ function parseLivePort(value: string | undefined): number { if (value === undefined || value.trim().length === 0) return 8765; const port = Number(value); if (!Number.isInteger(port) || port < 1024 || port > 65_535) { - throw new Error("GMAIL_LIVE_PORT must be an integer between 1024 and 65535"); + throw new Error( + "GMAIL_LIVE_PORT must be an integer between 1024 and 65535", + ); } return port; } -async function parseTokenResponse(response: Response): Promise> { +async function parseTokenResponse( + response: Response, +): Promise> { const raw = await response.text(); let body: Record | undefined; try { @@ -260,9 +277,12 @@ async function authorizeGmail( } stopServer(); code.resolve(authorizationCode); - return new Response("Authorization complete. You can close this window.", { - headers: { "Content-Type": "text/html" }, - }); + return new Response( + "Authorization complete. You can close this window.", + { + headers: { "Content-Type": "text/html" }, + }, + ); }, }); stopServer = () => server.stop(); @@ -279,11 +299,16 @@ async function authorizeGmail( state, }).toString(); - const timer = setTimeout(() => { - stopServer(); - code.reject(new Error("timed out waiting for Google OAuth callback")); - }, 5 * 60 * 1_000); - logger.info(`Open this URL to authorize the Gmail live test:\n${authorizationUrl}`); + const timer = setTimeout( + () => { + stopServer(); + code.reject(new Error("timed out waiting for Google OAuth callback")); + }, + 5 * 60 * 1_000, + ); + logger.info( + `Open this URL to authorize the Gmail live test:\n${authorizationUrl}`, + ); try { const child = Bun.spawn(["open", authorizationUrl.toString()], { stdout: "ignore", @@ -299,7 +324,12 @@ async function authorizeGmail( stopServer(); }); - return exchangeAuthorizationCode(config, authorizationCode, redirectUri, fetchImpl); + return exchangeAuthorizationCode( + config, + authorizationCode, + redirectUri, + fetchImpl, + ); } const GMAIL_API_ORIGIN = "https://gmail.googleapis.com"; @@ -350,7 +380,10 @@ function createStandaloneGmailCredential( } const headers = new Headers(sourceRequest?.headers); new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); - headers.set("Authorization", `Bearer ${await getAccessToken(forceRefresh)}`); + headers.set( + "Authorization", + `Bearer ${await getAccessToken(forceRefresh)}`, + ); return networkFetch( new Request(sourceRequest ?? url, { @@ -365,8 +398,13 @@ function createStandaloneGmailCredential( kind: "http", async fetch(input, init) { const response = await authenticatedFetch(input, init); - const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); - if (response.status !== 401 || !["GET", "HEAD", "OPTIONS"].includes(method)) { + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + if ( + response.status !== 401 || + !["GET", "HEAD", "OPTIONS"].includes(method) + ) { return response; } return authenticatedFetch(input, init, true); @@ -400,7 +438,9 @@ async function findGmailFixture( } const threads = getArray(result.content.data, "threads"); if (threads === undefined || threads.length !== 1) { - throw new Error(`fixture query must match exactly one Gmail thread: ${query}`); + throw new Error( + `fixture query must match exactly one Gmail thread: ${query}`, + ); } const thread = threads[0]; if (!isRecord(thread)) { @@ -409,10 +449,14 @@ async function findGmailFixture( const threadId = getString(thread, "id"); const messages = getArray(thread, "messages"); if (messages === undefined || messages.length !== 1) { - throw new Error(`fixture thread must contain exactly one Gmail message: ${query}`); + throw new Error( + `fixture thread must contain exactly one Gmail message: ${query}`, + ); } const firstMessage = messages[0]; - const messageId = isRecord(firstMessage) ? getString(firstMessage, "id") : undefined; + const messageId = isRecord(firstMessage) + ? getString(firstMessage, "id") + : undefined; if ( typeof threadId !== "string" || threadId.length === 0 || @@ -461,15 +505,22 @@ export async function createLiveHarness(): Promise { const query = process.env.GMAIL_LIVE_FIXTURE_QUERY?.trim(); if (query === undefined || query.length === 0) { await tools.dispose(); - throw new Error("GMAIL_LIVE_FIXTURE_QUERY is required for the live Gmail suite"); + throw new Error( + "GMAIL_LIVE_FIXTURE_QUERY is required for the live Gmail suite", + ); } try { - const fixture = await findGmailFixture(tools, query, new AbortController().signal); + const fixture = await findGmailFixture( + tools, + query, + new AbortController().signal, + ); return { tools, fixture, - deleteDraftsForSubject: (subject) => deleteDraftsForSubject(tools, credential, subject), + deleteDraftsForSubject: (subject) => + deleteDraftsForSubject(tools, credential, subject), async dispose() { await tools.dispose(); }, @@ -506,22 +557,33 @@ async function deleteDraftsForSubject( { method: "DELETE" }, ); if (!response.ok) { - throw new Error(`failed to delete live test draft: HTTP ${String(response.status)}`); + throw new Error( + `failed to delete live test draft: HTTP ${String(response.status)}`, + ); } } } -export function toolData(result: unknown, toolName: string): Record { - if (!isRecord(result) || result.isError === true || !isRecord(result.content)) { +export function toolData( + result: unknown, + toolName: string, +): Record { + if ( + !isRecord(result) || + result.isError === true || + !isRecord(result.content) + ) { throw new Error(`${toolName} failed`); } const data = result.content.data; - if (!isRecord(data)) throw new Error(`${toolName} did not return structured data`); + if (!isRecord(data)) + throw new Error(`${toolName} did not return structured data`); return data; } export function toolId(result: unknown, toolName: string): string { const id = getString(toolData(result, toolName), "id"); - if (id === undefined || id.length === 0) throw new Error(`${toolName} did not return an ID`); + if (id === undefined || id.length === 0) + throw new Error(`${toolName} did not return an ID`); return id; } diff --git a/package.json b/package.json index d5fc27f..68e1a83 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,31 @@ { "name": "@corbits/google-tools", "version": "0.1.1", - "type": "module", - "license": "LGPL-2.1-only", "description": "Corbits Google tools for Interchange, starting with Gmail.", "keywords": [ "corbits", - "google", "gmail", - "tools", - "interchange" + "google", + "interchange", + "tools" ], - "author": "Sawyer Cutler ", "homepage": "https://github.com/corbitsdev/google-tools#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/corbitsdev/google-tools.git" - }, "bugs": { "url": "https://github.com/corbitsdev/google-tools/issues" }, - "publishConfig": { - "access": "public" - }, - "engines": { - "node": ">=24" + "license": "LGPL-2.1-only", + "author": "Sawyer Cutler ", + "repository": { + "type": "git", + "url": "git+https://github.com/corbitsdev/google-tools.git" }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "type": "module", + "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -38,34 +38,22 @@ "default": "./dist/sidecar-bundle.js" } }, - "interchange": { - "tools": "./dist/sidecar-bundle.js", - "credentials": [ - { - "handle": "gmail-api", - "scopes": [ - "https://www.googleapis.com/auth/gmail.modify" - ] - } - ] + "publishConfig": { + "access": "public" }, - "files": [ - "dist", - "LICENSE", - "README.md" - ], - "sideEffects": false, "scripts": { "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", "typecheck:e2e": "tsc -p tsconfig.e2e.json --noEmit", "build": "rm -rf dist && tsc -p tsconfig.build.json", "prepack": "bun run build", "test": "bun test src", - "test:e2e": "bun test e2e" + "test:e2e": "bun test e2e", + "lint": "oxlint", + "format": "oxfmt", + "format:check": "oxfmt --check" }, - "peerDependencies": { - "@intx/agent": "^0.4.0", - "@intx/types": "^0.4.0" + "dependencies": { + "arktype": "^2.2.3" }, "devDependencies": { "@intx/agent": "0.4.0", @@ -73,9 +61,26 @@ "@intx/types": "0.4.0", "@types/bun": "1.3.14", "@types/node": "22.10.5", + "oxfmt": "0.70.0", + "oxlint": "1.85.0", "typescript": "5.9.3" }, - "dependencies": { - "arktype": "^2.2.3" + "peerDependencies": { + "@intx/agent": "^0.4.0", + "@intx/types": "^0.4.0" + }, + "engines": { + "node": ">=24" + }, + "interchange": { + "tools": "./dist/sidecar-bundle.js", + "credentials": [ + { + "handle": "gmail-api", + "scopes": [ + "https://www.googleapis.com/auth/gmail.modify" + ] + } + ] } } diff --git a/src/client/client.test.ts b/src/client/client.test.ts index 876796f..1a3fd19 100644 --- a/src/client/client.test.ts +++ b/src/client/client.test.ts @@ -50,7 +50,10 @@ describe("createGmailClient", () => { const client = createGmailClient({ fetchImpl }); await expect( - client.getMessage("message/1", { format: "metadata", metadataHeaders: [] }), + client.getMessage("message/1", { + format: "metadata", + metadataHeaders: [], + }), ).resolves.toEqual({ id: "message/1" }); }); @@ -63,7 +66,9 @@ describe("createGmailClient", () => { await expect( client.listMessages({ query: "in:inbox", pageSize: 10 }), - ).rejects.toThrow("Gmail API response did not match the expected list-messages shape"); + ).rejects.toThrow( + "Gmail API response did not match the expected list-messages shape", + ); }); test("rejects malformed Gmail headers", async () => { @@ -79,14 +84,18 @@ describe("createGmailClient", () => { await expect( client.getMessage("message-1", { format: "full" }), - ).rejects.toThrow("Gmail API response did not match the expected get-message shape"); + ).rejects.toThrow( + "Gmail API response did not match the expected get-message shape", + ); }); test("rejects an empty successful response body", async () => { const fetchImpl: GmailFetch = async () => new Response("", { status: 200 }); const client = createGmailClient({ fetchImpl }); - await expect(client.listLabels()).rejects.toThrow("response body was empty"); + await expect(client.listLabels()).rejects.toThrow( + "response body was empty", + ); }); test("maps non-success responses to GmailApiError", async () => { diff --git a/src/client/client.ts b/src/client/client.ts index 66268fa..5eebd0f 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -9,13 +9,11 @@ import { GmailThreadSchema, parseGmailResponse, type GmailDraft, - type GmailHeader, type GmailListDraftsResponse, type GmailListLabelsResponse, type GmailListMessagesResponse, type GmailListThreadsResponse, type GmailMessage, - type GmailMessagePart, type GmailResponseSchema, type GmailThread, } from "./schemas.js"; @@ -167,7 +165,9 @@ function responseBodyError( ); } -export function createGmailClient({ fetchImpl }: CreateGmailClientOptions): GmailClient { +export function createGmailClient({ + fetchImpl, +}: CreateGmailClientOptions): GmailClient { if (typeof fetchImpl !== "function") { throw new Error("createGmailClient: provide fetchImpl"); } @@ -208,7 +208,11 @@ export function createGmailClient({ fetchImpl }: CreateGmailClientOptions): Gmai try { raw = await response.text(); } catch (cause) { - throw responseBodyError(response.status, "could not read response body", cause); + throw responseBodyError( + response.status, + "could not read response body", + cause, + ); } if (!response.ok) { @@ -216,7 +220,11 @@ export function createGmailClient({ fetchImpl }: CreateGmailClientOptions): Gmai } if (raw.length === 0) { - throw responseBodyError(response.status, "response body was empty", undefined); + throw responseBodyError( + response.status, + "response body was empty", + undefined, + ); } let parsed: unknown; @@ -324,7 +332,9 @@ export function createGmailClient({ fetchImpl }: CreateGmailClientOptions): Gmai body: { message: { raw: options.raw, - ...(options.threadId === undefined ? {} : { threadId: options.threadId }), + ...(options.threadId === undefined + ? {} + : { threadId: options.threadId }), }, }, signal: options.signal, diff --git a/src/client/schemas.ts b/src/client/schemas.ts index 78f87c5..afd225a 100644 --- a/src/client/schemas.ts +++ b/src/client/schemas.ts @@ -77,17 +77,25 @@ export type GmailThread = typeof GmailThreadSchema.infer; export const GmailDraftSchema = GmailSchemas.GmailDraft; export type GmailDraft = typeof GmailDraftSchema.infer; -export const GmailListThreadsResponseSchema = GmailSchemas.GmailListThreadsResponse; -export type GmailListThreadsResponse = typeof GmailListThreadsResponseSchema.infer; +export const GmailListThreadsResponseSchema = + GmailSchemas.GmailListThreadsResponse; +export type GmailListThreadsResponse = + typeof GmailListThreadsResponseSchema.infer; -export const GmailListMessagesResponseSchema = GmailSchemas.GmailListMessagesResponse; -export type GmailListMessagesResponse = typeof GmailListMessagesResponseSchema.infer; +export const GmailListMessagesResponseSchema = + GmailSchemas.GmailListMessagesResponse; +export type GmailListMessagesResponse = + typeof GmailListMessagesResponseSchema.infer; -export const GmailListDraftsResponseSchema = GmailSchemas.GmailListDraftsResponse; -export type GmailListDraftsResponse = typeof GmailListDraftsResponseSchema.infer; +export const GmailListDraftsResponseSchema = + GmailSchemas.GmailListDraftsResponse; +export type GmailListDraftsResponse = + typeof GmailListDraftsResponseSchema.infer; -export const GmailListLabelsResponseSchema = GmailSchemas.GmailListLabelsResponse; -export type GmailListLabelsResponse = typeof GmailListLabelsResponseSchema.infer; +export const GmailListLabelsResponseSchema = + GmailSchemas.GmailListLabelsResponse; +export type GmailListLabelsResponse = + typeof GmailListLabelsResponseSchema.infer; export type GmailResponseSchema = (response: unknown) => T | ArkErrors; diff --git a/src/sidecar-bundle.test.ts b/src/sidecar-bundle.test.ts index f125d0b..3644749 100644 --- a/src/sidecar-bundle.test.ts +++ b/src/sidecar-bundle.test.ts @@ -47,8 +47,12 @@ afterAll(async () => { describe("gmail sidecar-bundle static declaration", () => { test("declared definition names match the instantiated bundle's names", async () => { const bundle = gmail(env); - const declared = new Set(gmail.definitions.map((definition) => definition.name)); - const emitted = new Set(bundle.definitions.map((definition) => definition.name)); + const declared = new Set( + gmail.definitions.map((definition) => definition.name), + ); + const emitted = new Set( + bundle.definitions.map((definition) => definition.name), + ); expect(emitted).toEqual(declared); await bundle.dispose?.(); }); diff --git a/src/tools/create-tools.test.ts b/src/tools/create-tools.test.ts index 55a92ca..ad3ef63 100644 --- a/src/tools/create-tools.test.ts +++ b/src/tools/create-tools.test.ts @@ -6,7 +6,10 @@ import { GMAIL_CREDENTIAL_HANDLE } from "./definitions.js"; import { createGmailTools } from "./create-tools.js"; import { createRawDraft } from "./drafts.js"; -function testCapabilities(fetchImpl: GmailFetch, handle = GMAIL_CREDENTIAL_HANDLE) { +function testCapabilities( + fetchImpl: GmailFetch, + handle = GMAIL_CREDENTIAL_HANDLE, +) { return createRuntimeCapabilities({ credentials: { async resolve(requested) { @@ -27,8 +30,11 @@ function encodeBase64Url(value: string): string { return Buffer.from(value).toString("base64url"); } -function jsonRequestBody(init: RequestInit | undefined): Record { - if (typeof init?.body !== "string") throw new Error("expected JSON string body"); +function jsonRequestBody( + init: RequestInit | undefined, +): Record { + if (typeof init?.body !== "string") + throw new Error("expected JSON string body"); const parsed: unknown = JSON.parse(init.body); if (!isRecord(parsed)) { throw new Error("expected JSON object body"); @@ -42,7 +48,8 @@ function draftRawFromRequest(init: RequestInit | undefined): string { if (!isRecord(message)) { throw new Error("expected draft message object"); } - if (typeof message.raw !== "string") throw new Error("expected draft raw message"); + if (typeof message.raw !== "string") + throw new Error("expected draft raw message"); return message.raw; } @@ -101,7 +108,9 @@ describe("createGmailTools", () => { ); }); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), + }); const result = await tools.run( { id: "call-1", @@ -147,19 +156,31 @@ describe("createGmailTools", () => { const url = new URL(String(input)); if (url.pathname === "/gmail/v1/users/me/threads") { return new Response( - JSON.stringify({ threads: Array.from({ length: 11 }, (_, index) => ({ id: `thread-${index + 1}` })) }), + JSON.stringify({ + threads: Array.from({ length: 11 }, (_, index) => ({ + id: `thread-${index + 1}`, + })), + }), ); } const id = url.pathname.split("/").at(-1); if (id === undefined) throw new Error("expected thread ID"); started.push(id); return new Promise((resolve) => { - resolveThread.set(id, () => resolve(new Response(JSON.stringify({ id, messages: [] })))); + resolveThread.set(id, () => + resolve(new Response(JSON.stringify({ id, messages: [] }))), + ); }); }); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), + }); const resultPromise = tools.run( - { id: "call-thread-limit", name: "gmail_search_threads", arguments: { pageSize: 11 } }, + { + id: "call-thread-limit", + name: "gmail_search_threads", + arguments: { pageSize: 11 }, + }, new AbortController().signal, ); @@ -168,7 +189,8 @@ describe("createGmailTools", () => { expect(resolveThread.has("thread-11")).toBe(false); for (const id of [...started].reverse()) { const resolve = resolveThread.get(id); - if (resolve === undefined) throw new Error(`missing thread resolver: ${id}`); + if (resolve === undefined) + throw new Error(`missing thread resolver: ${id}`); resolve(); } await new Promise((resolve) => setTimeout(resolve, 0)); @@ -179,7 +201,11 @@ describe("createGmailTools", () => { const result = await resultPromise; expect(result.content).toMatchObject({ - data: { threads: Array.from({ length: 11 }, (_, index) => ({ id: `thread-${index + 1}` })) }, + data: { + threads: Array.from({ length: 11 }, (_, index) => ({ + id: `thread-${index + 1}`, + })), + }, }); await tools.dispose(); }); @@ -191,20 +217,32 @@ describe("createGmailTools", () => { const url = new URL(String(input)); if (url.pathname === "/gmail/v1/users/me/threads") { return new Response( - JSON.stringify({ threads: Array.from({ length: 11 }, (_, index) => ({ id: `thread-${index + 1}` })) }), + JSON.stringify({ + threads: Array.from({ length: 11 }, (_, index) => ({ + id: `thread-${index + 1}`, + })), + }), ); } const id = url.pathname.split("/").at(-1); if (id === undefined) throw new Error("expected thread ID"); started.push(id); return new Promise((resolve) => { - resolveThread.set(id, () => resolve(new Response(JSON.stringify({ id, messages: [] })))); + resolveThread.set(id, () => + resolve(new Response(JSON.stringify({ id, messages: [] }))), + ); }); }); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), + }); const controller = new AbortController(); const resultPromise = tools.run( - { id: "call-thread-abort", name: "gmail_search_threads", arguments: { pageSize: 11 } }, + { + id: "call-thread-abort", + name: "gmail_search_threads", + arguments: { pageSize: 11 }, + }, controller.signal, ); @@ -213,7 +251,8 @@ describe("createGmailTools", () => { controller.abort(); for (const id of started) { const resolve = resolveThread.get(id); - if (resolve === undefined) throw new Error(`missing thread resolver: ${id}`); + if (resolve === undefined) + throw new Error(`missing thread resolver: ${id}`); resolve(); } @@ -242,8 +281,14 @@ describe("createGmailTools", () => { { name: "Date", value: "Tue, 19 Aug 2026 12:00:00 +0000" }, ], parts: [ - { mimeType: "text/plain", body: { data: encodeBase64Url(plaintextBody) } }, - { mimeType: "text/html", body: { data: encodeBase64Url("

Hello

") } }, + { + mimeType: "text/plain", + body: { data: encodeBase64Url(plaintextBody) }, + }, + { + mimeType: "text/html", + body: { data: encodeBase64Url("

Hello

") }, + }, { mimeType: "application/pdf", filename: "report.pdf", @@ -263,7 +308,9 @@ describe("createGmailTools", () => { ); }); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), + }); const result = await tools.run( { id: "call-2", @@ -303,19 +350,25 @@ describe("createGmailTools", () => { }); test("preserves commas inside quoted recipient display names", async () => { - const fetchImpl = createFetchImpl(async () => - new Response( - JSON.stringify({ - id: "message-1", - payload: { - headers: [ - { name: "To", value: '"Doe, John" , bob@example.com' }, - ], - }, - }), - ), + const fetchImpl = createFetchImpl( + async () => + new Response( + JSON.stringify({ + id: "message-1", + payload: { + headers: [ + { + name: "To", + value: '"Doe, John" , bob@example.com', + }, + ], + }, + }), + ), ); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), + }); const result = await tools.run( { @@ -353,7 +406,9 @@ describe("createGmailTools", () => { }), ); }); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), + }); const result = await tools.run( { @@ -380,7 +435,9 @@ describe("createGmailTools", () => { const fetchImpl = createFetchImpl(async () => { throw new Error("network should not run"); }); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), + }); const result = await tools.run( { @@ -468,18 +525,26 @@ describe("createGmailTools", () => { const url = new URL(String(input)); expect(url.pathname).toBe("/gmail/v1/users/me/drafts"); expect(init?.method).toBe("POST"); - expect(Buffer.from(draftRawFromRequest(init), "base64url").toString()).toContain( + expect( + Buffer.from(draftRawFromRequest(init), "base64url").toString(), + ).toContain( "To: ada@example.com\r\nSubject: Status\r\nMIME-Version: 1.0", ); return new Response(JSON.stringify({ id: "draft-1" })); }); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), + }); const result = await tools.run( { id: "call-draft", name: "gmail_create_draft", - arguments: { to: ["ada@example.com"], subject: "Status", body: "The body" }, + arguments: { + to: ["ada@example.com"], + subject: "Status", + body: "The body", + }, }, new AbortController().signal, ); @@ -534,7 +599,9 @@ describe("createGmailTools", () => { if (url.pathname === "/gmail/v1/users/me/drafts") { if (url.searchParams.get("maxResults") === "500") { return new Response( - JSON.stringify({ drafts: [{ id: "draft-1", message: { id: "message-1" } }] }), + JSON.stringify({ + drafts: [{ id: "draft-1", message: { id: "message-1" } }], + }), ); } expect(url.searchParams.get("maxResults")).toBe("20"); @@ -542,7 +609,9 @@ describe("createGmailTools", () => { } if (url.pathname === "/gmail/v1/users/me/messages") { expect(url.searchParams.get("q")).toBe("in:drafts subject:status"); - return new Response(JSON.stringify({ messages: [{ id: "message-1" }] })); + return new Response( + JSON.stringify({ messages: [{ id: "message-1" }] }), + ); } return new Response( JSON.stringify({ @@ -551,7 +620,9 @@ describe("createGmailTools", () => { }), ); }); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), + }); const result = await tools.run( { id: "call-list-drafts", name: "gmail_list_drafts", arguments: {} }, @@ -567,7 +638,9 @@ describe("createGmailTools", () => { }, new AbortController().signal, ); - expect(queryResult.content).toEqual({ data: { drafts: [{ id: "draft-1" }] } }); + expect(queryResult.content).toEqual({ + data: { drafts: [{ id: "draft-1" }] }, + }); await tools.dispose(); }); @@ -596,16 +669,30 @@ describe("createGmailTools", () => { ); } const id = url.pathname.split("/").at(-1); - return new Response(JSON.stringify({ id: `draft-${id?.at(-1)}`, message: { id, payload: { headers: [] } } })); + return new Response( + JSON.stringify({ + id: `draft-${id?.at(-1)}`, + message: { id, payload: { headers: [] } }, + }), + ); + }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), }); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); const result = await tools.run( - { id: "call-draft-page", name: "gmail_list_drafts", arguments: { query: "subject:status" } }, + { + id: "call-draft-page", + name: "gmail_list_drafts", + arguments: { query: "subject:status" }, + }, new AbortController().signal, ); expect(draftPageRequests).toBe(1); expect(result.content).toMatchObject({ - data: { drafts: [{ id: "draft-2" }, { id: "draft-1" }], nextPageToken: "query-next" }, + data: { + drafts: [{ id: "draft-2" }, { id: "draft-1" }], + nextPageToken: "query-next", + }, }); await tools.dispose(); }); @@ -614,39 +701,64 @@ describe("createGmailTools", () => { const fetchImpl = createFetchImpl(async (input) => { const url = new URL(String(input)); if (url.pathname === "/gmail/v1/users/me/messages") { - return new Response(JSON.stringify({ messages: [{ id: "message-1" }] })); + return new Response( + JSON.stringify({ messages: [{ id: "message-1" }] }), + ); } if (url.pathname === "/gmail/v1/users/me/drafts") { return new Response( url.searchParams.get("pageToken") === null ? JSON.stringify({ drafts: [], nextPageToken: "page-2" }) - : JSON.stringify({ drafts: [{ id: "draft-1", message: { id: "message-1" } }] }), + : JSON.stringify({ + drafts: [{ id: "draft-1", message: { id: "message-1" } }], + }), ); } - return new Response(JSON.stringify({ id: "draft-1", message: { id: "message-1", payload: { headers: [] } } })); + return new Response( + JSON.stringify({ + id: "draft-1", + message: { id: "message-1", payload: { headers: [] } }, + }), + ); + }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), }); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); const result = await tools.run( - { id: "call-draft-page-2", name: "gmail_list_drafts", arguments: { query: "subject:status" } }, + { + id: "call-draft-page-2", + name: "gmail_list_drafts", + arguments: { query: "subject:status" }, + }, new AbortController().signal, ); - expect(result.content).toMatchObject({ data: { drafts: [{ id: "draft-1" }] } }); + expect(result.content).toMatchObject({ + data: { drafts: [{ id: "draft-1" }] }, + }); await tools.dispose(); }); test("uses the original sender when a reply draft omits recipients", () => { const raw = createRawDraft( { body: "Reply" }, - { messageId: "", subject: "Original", to: ["ada@example.com"] }, + { + messageId: "", + subject: "Original", + to: ["ada@example.com"], + }, + ); + expect(Buffer.from(raw, "base64url").toString()).toContain( + "To: ada@example.com", ); - expect(Buffer.from(raw, "base64url").toString()).toContain("To: ada@example.com"); }); test("applies and removes labels on messages and threads", async () => { const fetchImpl = createFetchImpl(async (input, init) => { const url = new URL(String(input)); const request = jsonRequestBody(init); - expect(url.pathname).toMatch(/\/(messages|threads)\/(message-1|thread-1)\/modify$/); + expect(url.pathname).toMatch( + /\/(messages|threads)\/(message-1|thread-1)\/modify$/, + ); if (url.pathname.includes("threads")) { return new Response(JSON.stringify({ id: "thread-1", messages: [] })); } @@ -657,7 +769,9 @@ describe("createGmailTools", () => { }), ); }); - const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const tools = createGmailTools({ + capabilities: testCapabilities(fetchImpl), + }); const signal = new AbortController().signal; for (const name of [ @@ -671,7 +785,12 @@ describe("createGmailTools", () => { { id: name, name, - arguments: { [isThread ? "threadId" : "messageId"]: isThread ? "thread-1" : "message-1", labelIds: ["STARRED"] }, + arguments: { + [isThread ? "threadId" : "messageId"]: isThread + ? "thread-1" + : "message-1", + labelIds: ["STARRED"], + }, }, signal, ); diff --git a/src/tools/create-tools.ts b/src/tools/create-tools.ts index 56a4a38..b9bbb55 100644 --- a/src/tools/create-tools.ts +++ b/src/tools/create-tools.ts @@ -65,9 +65,13 @@ function parseToolInput( return result; } -function rejectUnsupportedAttachments(attachments: readonly unknown[] | undefined): void { +function rejectUnsupportedAttachments( + attachments: readonly unknown[] | undefined, +): void { if (attachments !== undefined && attachments.length > 0) { - throw new Error("gmail_create_draft: attachments are not supported by Gmail MCP drafts"); + throw new Error( + "gmail_create_draft: attachments are not supported by Gmail MCP drafts", + ); } } @@ -82,12 +86,16 @@ function toGmailFormat(format: GmailMessageFormat): GmailFormat { } } -function metadataHeadersForFormat(format: GmailMessageFormat): readonly string[] | undefined { +function metadataHeadersForFormat( + format: GmailMessageFormat, +): readonly string[] | undefined { if (format === "METADATA_ONLY") return undefined; return ["Subject", "From", "To", "Cc", "Date"]; } -function draftFormat(view: "DRAFT_VIEW_FULL" | "DRAFT_VIEW_METADATA_ONLY"): GmailMessageFormat { +function draftFormat( + view: "DRAFT_VIEW_FULL" | "DRAFT_VIEW_METADATA_ONLY", +): GmailMessageFormat { return view === "DRAFT_VIEW_FULL" ? "FULL_CONTENT" : "METADATA_ONLY"; } @@ -112,20 +120,31 @@ async function replyContext( signal: AbortSignal, ): Promise { if (messageId === undefined) return undefined; - const original = await client.getMessage(messageId, { format: "full", signal }); + const original = await client.getMessage(messageId, { + format: "full", + signal, + }); const normalized = toToolMessage(original, "FULL_CONTENT"); const originalMessageId = headerValue(original, "Message-ID"); const originalReferences = headerValue(original, "References"); const recipient = inputRecipients(original, normalized); return { ...(original.threadId === undefined ? {} : { threadId: original.threadId }), - ...(originalMessageId === undefined ? {} : { messageId: originalMessageId }), - ...(originalReferences === undefined ? {} : { references: originalReferences }), - ...(normalized.subject === undefined ? {} : { subject: normalized.subject }), + ...(originalMessageId === undefined + ? {} + : { messageId: originalMessageId }), + ...(originalReferences === undefined + ? {} + : { references: originalReferences }), + ...(normalized.subject === undefined + ? {} + : { subject: normalized.subject }), ...(normalized.plaintextBody === undefined ? {} : { plaintextBody: normalized.plaintextBody }), - ...(normalized.htmlBody === undefined ? {} : { htmlBody: normalized.htmlBody }), + ...(normalized.htmlBody === undefined + ? {} + : { htmlBody: normalized.htmlBody }), ...(recipient === undefined ? {} : { to: recipient }), }; } @@ -138,7 +157,9 @@ function inputRecipients( ? normalized.toRecipients?.[0] : normalized.sender; const recipient = plainEmailAddress(replyAddress); - return recipient === undefined || recipient.length === 0 ? undefined : [recipient]; + return recipient === undefined || recipient.length === 0 + ? undefined + : [recipient]; } async function listDraftsForQuery( @@ -154,7 +175,9 @@ async function listDraftsForQuery( pageToken, signal, }); - const matchingMessageIds = new Set((matchingMessages.messages ?? []).map((message) => message.id)); + const matchingMessageIds = new Set( + (matchingMessages.messages ?? []).map((message) => message.id), + ); const draftsByMessageId = new Map(); let draftsPageToken: string | undefined; while (matchingMessageIds.size > 0) { @@ -164,7 +187,10 @@ async function listDraftsForQuery( signal, }); for (const draft of draftPage.drafts ?? []) { - if (draft.message?.id !== undefined && matchingMessageIds.delete(draft.message.id)) { + if ( + draft.message?.id !== undefined && + matchingMessageIds.delete(draft.message.id) + ) { draftsByMessageId.set(draft.message.id, draft); } } @@ -190,10 +216,12 @@ async function lookupThreads( view: GmailThreadView, signal: AbortSignal, ) { - const threads = new Array<{ + const threads = Array.from<{ id: string; messages: ReturnType[]; - }>(threadIds.length); + }>({ + length: threadIds.length, + }); let nextIndex = 0; let failed = false; const worker = async () => { @@ -212,7 +240,9 @@ async function lookupThreads( }); threads[index] = { id: fullThread.id, - messages: (fullThread.messages ?? []).map((message) => toThreadListMessage(message, view)), + messages: (fullThread.messages ?? []).map((message) => + toThreadListMessage(message, view), + ), }; } catch (error) { failed = true; @@ -221,7 +251,10 @@ async function lookupThreads( } }; await Promise.all( - Array.from({ length: Math.min(threadIds.length, MAX_THREAD_LOOKUPS_IN_FLIGHT) }, worker), + Array.from( + { length: Math.min(threadIds.length, MAX_THREAD_LOOKUPS_IN_FLIGHT) }, + worker, + ), ); return threads; } @@ -443,7 +476,8 @@ export function createGmailTools(opts: CreateGmailToolsOptions): GmailTools { ); } mediated = resolved; - const fetchImpl: GmailFetch = (input, init) => resolved.fetch(input, init); + const fetchImpl: GmailFetch = (input, init) => + resolved.fetch(input, init); return createGmailClient({ fetchImpl, }); @@ -493,7 +527,9 @@ export function createGmailTools(opts: CreateGmailToolsOptions): GmailTools { } function assertCatalogMatchesHandlers(): void { - const definitions = TOOL_DEFINITIONS.map((definition) => definition.name).sort(); + const definitions = TOOL_DEFINITIONS.map( + (definition) => definition.name, + ).sort(); const handlers = [...HANDLERS.keys()].sort(); if (JSON.stringify(definitions) !== JSON.stringify(handlers)) { throw new Error( diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts index 2f6e2fd..8bdaa5a 100644 --- a/src/tools/definitions.ts +++ b/src/tools/definitions.ts @@ -77,7 +77,8 @@ export const GMAIL_TOOL_CATALOG: GmailToolCatalogEntry[] = [ }, { name: "gmail_unlabel_thread", - description: "Remove one or more labels from every message in a Gmail thread.", + description: + "Remove one or more labels from every message in a Gmail thread.", inputSchema: createInputJSONSchema(UnlabelThreadInput), approval: "ask", }, diff --git a/src/tools/drafts.ts b/src/tools/drafts.ts index 13ef6f8..0819cbd 100644 --- a/src/tools/drafts.ts +++ b/src/tools/drafts.ts @@ -23,7 +23,10 @@ function assertSafeHeader(value: string, name: string): void { } } -function recipientHeader(name: string, addresses: readonly string[] | undefined): string[] { +function recipientHeader( + name: string, + addresses: readonly string[] | undefined, +): string[] { if (addresses === undefined || addresses.length === 0) return []; for (const address of addresses) { assertSafeHeader(address, name); @@ -40,7 +43,10 @@ function escapeHtml(value: string): string { .replaceAll("'", "'"); } -function replyBodies(input: DraftInput, reply: ReplyContext | undefined): { +function replyBodies( + input: DraftInput, + reply: ReplyContext | undefined, +): { plaintext: string; html: string | undefined; } { @@ -56,7 +62,8 @@ function replyBodies(input: DraftInput, reply: ReplyContext | undefined): { ? body : `${body}\r\n\r\n${quotedPlaintext}`; const quotedHtml = reply.htmlBody; - const richBody = html ?? (body.length === 0 ? undefined : `

${escapeHtml(body)}

`); + const richBody = + html ?? (body.length === 0 ? undefined : `

${escapeHtml(body)}

`); const mergedHtml = richBody === undefined && quotedHtml === undefined ? undefined @@ -68,10 +75,16 @@ function encodeBase64Url(value: string): string { const bytes = new TextEncoder().encode(value); let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); } -export function createRawDraft(input: DraftInput, reply?: ReplyContext): string { +export function createRawDraft( + input: DraftInput, + reply?: ReplyContext, +): string { const subject = input.subject ?? reply?.subject ?? ""; assertSafeHeader(subject, "subject"); const headers = [ diff --git a/src/tools/models.ts b/src/tools/models.ts index 5c99f00..34b2837 100644 --- a/src/tools/models.ts +++ b/src/tools/models.ts @@ -42,15 +42,26 @@ export type GmailToolThread = { export type GmailToolDraft = Omit< GmailToolMessage, - "id" | "labelIds" | "sizeEstimate" | "snippet" | "sender" | "attachmentIds" | "attachments" + | "id" + | "labelIds" + | "sizeEstimate" + | "snippet" + | "sender" + | "attachmentIds" + | "attachments" > & { id: string; }; const THREAD_METADATA_HEADERS: readonly string[] = ["From", "To", "Cc", "Date"]; -const THREAD_MINIMAL_HEADERS: readonly string[] = ["Subject", ...THREAD_METADATA_HEADERS]; +const THREAD_MINIMAL_HEADERS: readonly string[] = [ + "Subject", + ...THREAD_METADATA_HEADERS, +]; -function headersByName(headers: GmailHeader[] | undefined): Map { +function headersByName( + headers: GmailHeader[] | undefined, +): Map { const result = new Map(); for (const header of headers ?? []) { const name = header.name.trim().toLowerCase(); @@ -72,7 +83,10 @@ function collectParts(part: GmailMessagePart | undefined): GmailMessagePart[] { return [part, ...(part.parts ?? []).flatMap(collectParts)]; } -function bodyForMimeType(parts: GmailMessagePart[], mimeType: string): string | undefined { +function bodyForMimeType( + parts: GmailMessagePart[], + mimeType: string, +): string | undefined { const values = parts.flatMap((part) => { if ( part.mimeType !== mimeType || @@ -122,10 +136,14 @@ function recipients(value: string | undefined): string[] | undefined { return result; } -function dateFromMessage(message: GmailMessage, headerDate: string | undefined): string | undefined { +function dateFromMessage( + message: GmailMessage, + headerDate: string | undefined, +): string | undefined { const source = headerDate ?? message.internalDate; if (source === undefined) return undefined; - const timestamp = headerDate === undefined ? Number(source) : Date.parse(source); + const timestamp = + headerDate === undefined ? Number(source) : Date.parse(source); if (!Number.isFinite(timestamp)) return undefined; return new Date(timestamp).toISOString().slice(0, 10); } @@ -137,23 +155,32 @@ function baseMessage(message: GmailMessage): GmailToolMessage { id: message.id, ...(message.threadId === undefined ? {} : { threadId: message.threadId }), labelIds: message.labelIds ?? [], - ...(message.sizeEstimate === undefined ? {} : { sizeEstimate: message.sizeEstimate }), + ...(message.sizeEstimate === undefined + ? {} + : { sizeEstimate: message.sizeEstimate }), ...(date === undefined ? {} : { date }), }; } -function messageMetadata(message: GmailMessage, includeSubject: boolean): GmailToolMessage { +function messageMetadata( + message: GmailMessage, + includeSubject: boolean, +): GmailToolMessage { const headers = headersByName(message.payload?.headers); const toRecipients = recipients(headers.get("to")); const ccRecipients = recipients(headers.get("cc")); const bccRecipients = recipients(headers.get("bcc")); return { ...baseMessage(message), - ...(includeSubject && message.snippet !== undefined ? { snippet: message.snippet } : {}), + ...(includeSubject && message.snippet !== undefined + ? { snippet: message.snippet } + : {}), ...(includeSubject && headers.get("subject") !== undefined ? { subject: headers.get("subject") } : {}), - ...(headers.get("from") === undefined ? {} : { sender: headers.get("from") }), + ...(headers.get("from") === undefined + ? {} + : { sender: headers.get("from") }), ...(toRecipients === undefined ? {} : { toRecipients }), ...(ccRecipients === undefined ? {} : { ccRecipients }), ...(bccRecipients === undefined ? {} : { bccRecipients }), @@ -176,7 +203,9 @@ function attachments(parts: GmailMessagePart[]): GmailAttachment[] { }); } -export function metadataHeadersForView(view: GmailThreadView): readonly string[] { +export function metadataHeadersForView( + view: GmailThreadView, +): readonly string[] { return view === "THREAD_VIEW_MINIMAL" ? THREAD_MINIMAL_HEADERS : THREAD_METADATA_HEADERS; @@ -214,7 +243,9 @@ export function toToolThread( ): GmailToolThread { return { id: thread.id, - messages: (thread.messages ?? []).map((message) => toToolMessage(message, format)), + messages: (thread.messages ?? []).map((message) => + toToolMessage(message, format), + ), }; }