diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f9819..d4e22ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,19 @@ # Changelog +## 0.2.0 + +Fork of @mariozechner/gmcli with bug fixes. + +### Fixed +- `--reply-to` now accepts both thread IDs and message IDs. Previously it only worked with message IDs, but `search` returns thread IDs. Now automatically detects thread IDs and fetches the last message in the thread for proper reply threading. +- `drafts send` no longer crashes with "Cannot read property 'id' of undefined". Fixed response handling from Gmail API. + +### Changed +- Published as `@willtmc/gmcli` (fork of `@mariozechner/gmcli`) + ## 0.1.0 -Initial release. +Initial release (original author: Mario Zechner). - Account management (add, remove, list) - `--manual` flag for browserless OAuth (paste redirect URL) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a824ce5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Development Commands + +```bash +npm run build # Compile TypeScript and make CLI executable +npm run check # Lint with Biome and type-check with tsgo +npm test # Run tests in watch mode +npm run test:run # Run tests once +``` + +After building, test CLI directly: `node dist/cli.js ` + +## Architecture + +This is a Gmail CLI tool built on the Gmail API. Key files: + +- **`src/cli.ts`** - Main entry point. Parses args and dispatches to handler functions (`handleSearch`, `handleThread`, `handleDrafts`, `handleSend`, etc.). Contains the `usage()` help text. + +- **`src/gmail-service.ts`** - Core service class wrapping Gmail API. Handles OAuth client management, thread/message fetching, draft creation, sending. Key methods: + - `getMessageForReply()` - Fetches message data for reply (always gets last message in thread) + - `createDraft()` / `sendMessage()` - Build MIME messages with multipart/alternative (text + HTML) for proper Gmail quote rendering + +- **`src/reply-utils.ts`** - Pure helper functions for reply formatting: + - `formatGmailQuote()` / `formatHtmlGmailQuote()` - Format quoted text in Gmail style + - `parseEmailAddress()` / `parseEmailList()` - Parse email headers + - `formatReplySubject()` - Add "Re:" prefix + +- **`src/account-storage.ts`** - Persists OAuth credentials and account tokens to `~/.gmcli/` + +- **`src/gmail-oauth-flow.ts`** - OAuth2 authentication flow (browser or manual mode) + +## Reply/Quote System + +When replying to messages, the CLI sends `multipart/alternative` emails with both: +1. `text/plain` - Plain text with `>` quote markers +2. `text/html` - HTML with Gmail's blockquote styling (`gmail_quote` class) + +The HTML body from the original message is preserved to maintain nested quote structure. + +## Data Storage + +All persistent data in `~/.gmcli/`: +- `credentials.json` - OAuth client credentials +- `accounts.json` - Account refresh tokens +- `attachments/` - Downloaded attachments diff --git a/README.md b/README.md index 1963f56..5a66a54 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,12 @@ Minimal Gmail CLI for searching, reading threads, managing labels, drafts, and sending emails. +> **Fork Notice:** This is a fork of [@mariozechner/gmcli](https://github.com/badlogic/gmcli) by Mario Zechner with bug fixes for `--reply-to` thread ID handling and `drafts send` crash. See [CHANGELOG.md](CHANGELOG.md) for details. + ## Install ```bash -npm install -g @mariozechner/gmcli +npm install -g @willtmc/gmcli ``` ## Setup diff --git a/package-lock.json b/package-lock.json index 060f16e..d347bc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@mariozechner/gmcli", - "version": "0.1.0", + "name": "@willtmc/gmcli", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@mariozechner/gmcli", - "version": "0.1.0", + "name": "@willtmc/gmcli", + "version": "0.2.0", "license": "MIT", "dependencies": { "google-auth-library": "^10.1.0", @@ -20,7 +20,8 @@ "@types/node": "^22.10.1", "@typescript/native-preview": "^7.0.0-dev.20251204.1", "husky": "^9.1.7", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^3.0.0" }, "engines": { "node": ">=20.0.0" @@ -59,163 +60,987 @@ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", "cpu": [ - "arm64" + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } + "linux" + ] }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", - "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", "cpu": [ - "x64" + "ppc64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } + "linux" + ] }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", - "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" ], - "engines": { - "node": ">=14.21.3" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", - "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", "cpu": [ - "arm64" + "riscv64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" ], - "engines": { - "node": ">=14.21.3" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", - "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": ">=14.21.3" - } + ] }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", - "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", "cpu": [ "x64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" ], - "engines": { - "node": ">=14.21.3" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", - "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" ], - "engines": { - "node": ">=14.21.3" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", - "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" ], - "engines": { - "node": ">=14.21.3" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { "version": "22.19.1", @@ -344,6 +1169,121 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -377,6 +1317,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -427,6 +1377,16 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -456,6 +1416,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -514,6 +1501,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -567,6 +1564,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -579,12 +1583,92 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -636,6 +1720,21 @@ "node": ">=12.20.0" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -895,6 +1994,13 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -925,12 +2031,29 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -970,6 +2093,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -1051,6 +2193,72 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -1081,6 +2289,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1194,6 +2447,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -1206,6 +2466,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -1302,6 +2586,80 @@ "node": ">=8" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1329,6 +2687,177 @@ "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", "license": "BSD" }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -1353,6 +2882,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/package.json b/package.json index 34b5c3f..97a8125 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,23 @@ { - "name": "@mariozechner/gmcli", - "version": "0.1.0", - "description": "Minimal Gmail CLI", + "name": "@willtmc/gmcli", + "version": "0.2.0", + "description": "Minimal Gmail CLI (fork with bug fixes)", + "repository": { + "type": "git", + "url": "https://github.com/willtmc/gmcli.git" + }, "type": "module", "bin": { "gmcli": "dist/cli.js" }, "main": "./dist/gmail-service.js", "types": "./dist/gmail-service.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "build": "tsc && chmod +x dist/cli.js", "check": "biome check src/ --write --diagnostic-level=error && tsgo --noEmit", + "test": "vitest", + "test:run": "vitest run", "prepublishOnly": "npm run build", "prepare": "husky" }, @@ -26,13 +30,10 @@ "@types/node": "^22.10.1", "@typescript/native-preview": "^7.0.0-dev.20251204.1", "husky": "^9.1.7", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^3.0.0" }, - "keywords": [ - "gmail", - "email", - "cli" - ], + "keywords": ["gmail", "email", "cli"], "author": "", "license": "MIT", "engines": { diff --git a/src/cli.ts b/src/cli.ts index e484f0c..e7ec2f0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -65,16 +65,26 @@ GMAIL COMMANDS gmcli drafts create --to --subject --body [options] Create a new draft. + gmcli drafts create --reply-to --body [--all] [--no-quote] + Reply to a message. Auto-fills To, Subject, and quoted text. + --all: Reply to all recipients (sender + To + Cc) + --no-quote: Don't include quoted original message + gmcli send --to --subject --body [options] Send an email directly. + gmcli send --reply-to --body [--all] [--no-quote] + Reply and send immediately. Auto-fills To, Subject, and quoted text. + Options for drafts create / send: - --to Recipients (comma-separated, required) - --subject Subject line (required) + --to Recipients (comma-separated, required unless replying) + --subject Subject line (required unless replying) --body Message body (required) --cc CC recipients (comma-separated) --bcc BCC recipients (comma-separated) - --reply-to Reply to message (sets headers and thread) + --reply-to Reply to message/thread (sets headers, auto-fills To/Subject) + --all Reply to all recipients (use with --reply-to) + --no-quote Don't include quoted original message --attach Attach file (use multiple times for multiple files) gmcli url @@ -91,6 +101,8 @@ EXAMPLES gmcli you@gmail.com labels list gmcli you@gmail.com labels abc123 --add Work --remove UNREAD gmcli you@gmail.com drafts create --to a@x.com --subject "Hi" --body "Hello" + gmcli you@gmail.com drafts create --reply-to 19aea1f2f3532db5 --body "Thanks!" + gmcli you@gmail.com drafts create --reply-to 19aea1f2f3532db5 --body "Reply" --all gmcli you@gmail.com drafts send r1234567890 gmcli you@gmail.com send --to a@x.com --subject "Hi" --body "Hello" gmcli you@gmail.com send --to a@x.com --subject "Re: Topic" \\ @@ -230,14 +242,14 @@ async function handleSearch(account: string, args: string[]) { if (results.threads.length === 0) { console.log("No results"); } else { - console.log("ID\tDATE\tFROM\tSUBJECT\tLABELS"); + console.log("THREAD_ID\tMESSAGE_ID\tDATE\tFROM\tSUBJECT\tLABELS"); for (const t of results.threads) { - const msg = t.messages[0]; + const msg = t.messages.at(-1); const date = msg?.date ? new Date(msg.date).toISOString().slice(0, 16).replace("T", " ") : ""; const from = msg?.from?.replace(/\t/g, " ") || ""; const subject = msg?.subject?.replace(/\t/g, " ") || "(no subject)"; const labels = msg?.labelIds?.map((id) => idToName.get(id) || id).join(",") || ""; - console.log(`${t.id}\t${date}\t${from}\t${subject}\t${labels}`); + console.log(`${t.id}\t${msg?.id || ""}\t${date}\t${from}\t${subject}\t${labels}`); } if (results.nextPageToken) { console.log(`\n# Next page: --page ${results.nextPageToken}`); @@ -461,18 +473,36 @@ async function handleDrafts(account: string, args: string[]) { thread: { type: "string" }, "reply-to": { type: "string" }, attach: { type: "string", multiple: true }, + all: { type: "boolean" }, + "no-quote": { type: "boolean" }, }, }); - if (!values.to || !values.subject || !values.body) { - error("Usage: drafts create --to --subject --body "); + if (!values["reply-to"]) { + // Not a reply - require to, subject, body + if (!values.to || !values.subject || !values.body) { + error("Usage: drafts create --to --subject --body "); + } + } else { + // Reply mode - only body is required + if (!values.body) { + error("Usage for reply: drafts create --reply-to --body [--all] [--no-quote]"); + } } - const draft = await service.createDraft(account, values.to.split(","), values.subject, values.body, { - cc: values.cc?.split(","), - bcc: values.bcc?.split(","), - threadId: values.thread, - replyToMessageId: values["reply-to"], - attachments: values.attach, - }); + const draft = await service.createDraft( + account, + values.to?.split(",") || [], + values.subject || "", + values.body, + { + cc: values.cc?.split(","), + bcc: values.bcc?.split(","), + threadId: values.thread, + replyToMessageId: values["reply-to"], + attachments: values.attach, + replyAll: values.all, + includeQuote: !values["no-quote"], + }, + ); console.log(`Draft created: ${draft.id}`); break; } @@ -492,18 +522,28 @@ async function handleSend(account: string, args: string[]) { body: { type: "string" }, "reply-to": { type: "string" }, attach: { type: "string", multiple: true }, + all: { type: "boolean" }, + "no-quote": { type: "boolean" }, }, }); - if (!values.to || !values.subject || !values.body) { - error("Usage: send --to --subject --body "); + if (!values["reply-to"]) { + if (!values.to || !values.subject || !values.body) { + error("Usage: send --to --subject --body "); + } + } else { + if (!values.body) { + error("Usage for reply: send --reply-to --body [--all] [--no-quote]"); + } } - const msg = await service.sendMessage(account, values.to.split(","), values.subject, values.body, { + const msg = await service.sendMessage(account, values.to?.split(",") || [], values.subject || "", values.body, { cc: values.cc?.split(","), bcc: values.bcc?.split(","), replyToMessageId: values["reply-to"], attachments: values.attach, + replyAll: values.all, + includeQuote: !values["no-quote"], }); console.log(`Sent: ${msg.id}`); } diff --git a/src/gmail-service.ts b/src/gmail-service.ts index d218f29..7949c4c 100644 --- a/src/gmail-service.ts +++ b/src/gmail-service.ts @@ -5,6 +5,14 @@ import { OAuth2Client } from "google-auth-library"; import { type gmail_v1, google } from "googleapis"; import { AccountStorage } from "./account-storage.js"; import { GmailOAuthFlow } from "./gmail-oauth-flow.js"; +import { + filterSelfFromRecipients, + formatGmailQuote, + formatHtmlReplyBody, + formatReplySubject, + parseEmailAddress, + parseEmailList, +} from "./reply-utils.js"; import type { EmailAccount } from "./types.js"; type GmailMessage = gmail_v1.Schema$Message; @@ -357,6 +365,8 @@ export class GmailService { threadId?: string; replyToMessageId?: string; attachments?: string[]; + replyAll?: boolean; + includeQuote?: boolean; } = {}, ): Promise { const gmail = this.getGmailClient(email); @@ -364,41 +374,71 @@ export class GmailService { let inReplyTo: string | undefined; let references: string | undefined; let threadId = options.threadId; + let replyData: Awaited> | undefined; + const originalBody = body; // Keep original reply text for HTML generation - // If replying to a specific message, fetch its headers + // Use local variables to avoid parameter reassignment + let recipientList = to; + let subjectLine = subject; + let bodyText = body; + + // If replying to a specific message, use getMessageForReply for auto-fill if (options.replyToMessageId) { - const msg = await gmail.users.messages.get({ - userId: "me", - id: options.replyToMessageId, - format: "metadata", - metadataHeaders: ["Message-ID", "References"], - }); - const headers = msg.data.payload?.headers || []; - const messageId = headers.find((h: any) => h.name === "Message-ID")?.value; - const existingRefs = headers.find((h: any) => h.name === "References")?.value; + replyData = await this.getMessageForReply(email, options.replyToMessageId); + + // Auto-fill To if not provided + if (!recipientList || recipientList.length === 0 || (recipientList.length === 1 && !recipientList[0])) { + const senderEmail = parseEmailAddress(replyData.from).email; + recipientList = [senderEmail]; + + if (options.replyAll) { + // Add original To + Cc (minus self) to Cc + const allRecipients = [...replyData.to, ...replyData.cc]; + const filtered = filterSelfFromRecipients(allRecipients, email); + options.cc = [...(options.cc || []), ...filtered]; + } + } - if (messageId) { - inReplyTo = messageId; - references = existingRefs ? `${existingRefs} ${messageId}` : messageId; + // Auto-fill subject if not provided + if (!subjectLine) { + subjectLine = formatReplySubject(replyData.subject); } - threadId = threadId || msg.data.threadId; + + // Append quoted text to plain text body if includeQuote !== false + if (options.includeQuote !== false) { + bodyText = bodyText + formatGmailQuote(replyData.date, replyData.from, replyData.body); + } + + inReplyTo = replyData.inReplyTo; + references = replyData.references; + threadId = replyData.threadId; } const hasAttachments = options.attachments && options.attachments.length > 0; + const hasHtmlQuote = replyData && options.includeQuote !== false; const boundary = `boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const altBoundary = `alt_${Date.now()}_${Math.random().toString(36).slice(2)}`; + + // Determine content type based on attachments and HTML quote + let contentType: string; + if (hasAttachments) { + contentType = `multipart/mixed; boundary="${boundary}"`; + } else if (hasHtmlQuote) { + contentType = `multipart/alternative; boundary="${altBoundary}"`; + } else { + contentType = "text/plain; charset=UTF-8"; + } const headers = [ `From: ${email}`, - `To: ${to.join(", ")}`, + `To: ${recipientList.join(", ")}`, options.cc?.length ? `Cc: ${options.cc.join(", ")}` : "", options.bcc?.length ? `Bcc: ${options.bcc.join(", ")}` : "", - `Subject: ${subject}`, + `Subject: ${subjectLine}`, inReplyTo ? `In-Reply-To: ${inReplyTo}` : "", references ? `References: ${references}` : "", "MIME-Version: 1.0", - hasAttachments - ? `Content-Type: multipart/mixed; boundary="${boundary}"` - : "Content-Type: text/plain; charset=UTF-8", + `Content-Type: ${contentType}`, ].filter(Boolean); let emailContent: string; @@ -406,8 +446,30 @@ export class GmailService { if (hasAttachments) { const parts: string[] = []; - // Text body part - parts.push(`--${boundary}\r\n` + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + body); + if (hasHtmlQuote) { + // Multipart/alternative inside multipart/mixed + const htmlBody = formatHtmlReplyBody( + originalBody, + replyData!.date, + replyData!.from, + replyData!.body, + replyData!.htmlBody, + ); + parts.push( + `--${boundary}\r\n` + + `Content-Type: multipart/alternative; boundary="${altBoundary}"\r\n\r\n` + + `--${altBoundary}\r\n` + + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + + bodyText + + `\r\n--${altBoundary}\r\n` + + "Content-Type: text/html; charset=UTF-8\r\n\r\n" + + htmlBody + + `\r\n--${altBoundary}--`, + ); + } else { + // Text body part only + parts.push(`--${boundary}\r\n` + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + bodyText); + } // Attachment parts for (const filePath of options.attachments!) { @@ -426,8 +488,22 @@ export class GmailService { } emailContent = headers.join("\r\n") + "\r\n\r\n" + parts.join("\r\n") + `\r\n--${boundary}--`; + } else if (hasHtmlQuote) { + // Multipart/alternative with text and HTML + const htmlBody = formatHtmlReplyBody( + originalBody, + replyData!.date, + replyData!.from, + replyData!.body, + replyData!.htmlBody, + ); + const parts = [ + `--${altBoundary}\r\n` + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + bodyText, + `--${altBoundary}\r\n` + "Content-Type: text/html; charset=UTF-8\r\n\r\n" + htmlBody, + ]; + emailContent = headers.join("\r\n") + "\r\n\r\n" + parts.join("\r\n") + `\r\n--${altBoundary}--`; } else { - emailContent = headers.join("\r\n") + "\r\n\r\n" + body; + emailContent = headers.join("\r\n") + "\r\n\r\n" + bodyText; } const encodedEmail = Buffer.from(emailContent).toString("base64url"); @@ -493,6 +569,122 @@ export class GmailService { return response.data; } + async getMessageForReply( + email: string, + messageOrThreadId: string, + ): Promise<{ + messageId: string; + threadId: string; + from: string; + to: string[]; + cc: string[]; + subject: string; + date: string; + body: string; + htmlBody: string; + inReplyTo: string; + references: string; + }> { + const gmail = this.getGmailClient(email); + + let messageIdToFetch = messageOrThreadId; + + // Try to get as a thread first to get the LAST message (for proper reply threading) + // This is important because thread IDs are often the same as the first message ID + try { + const thread = await gmail.users.threads.get({ + userId: "me", + id: messageOrThreadId, + format: "minimal", + }); + if (thread.data.messages && thread.data.messages.length > 0) { + // Use the last message in the thread + messageIdToFetch = thread.data.messages[thread.data.messages.length - 1].id!; + } + } catch { + // Not a thread ID - treat as a direct message ID + messageIdToFetch = messageOrThreadId; + } + + // Fetch the message with full format to get body + const msg = await gmail.users.messages.get({ + userId: "me", + id: messageIdToFetch, + format: "full", + }); + + const headers = msg.data.payload?.headers || []; + const getHeader = (name: string): string => + headers.find((h: any) => h.name?.toLowerCase() === name.toLowerCase())?.value || ""; + + const from = getHeader("From"); + const to = parseEmailList(getHeader("To")); + const cc = parseEmailList(getHeader("Cc")); + const subject = getHeader("Subject"); + const date = getHeader("Date"); + const rfc822MessageId = getHeader("Message-ID"); + const existingRefs = getHeader("References"); + + // Build references: existing References + Message-ID + const references = existingRefs ? `${existingRefs} ${rfc822MessageId}` : rfc822MessageId; + + // Decode body from payload + const body = this.decodeMessageBody(msg.data.payload); + const htmlBody = this.decodeHtmlBody(msg.data.payload); + + return { + messageId: msg.data.id || "", + threadId: msg.data.threadId || "", + from, + to, + cc, + subject, + date, + body, + htmlBody, + inReplyTo: rfc822MessageId, + references, + }; + } + + private decodeMessageBody(payload: any): string { + if (!payload) return ""; + if (payload.body?.data) { + return Buffer.from(payload.body.data, "base64url").toString(); + } + if (payload.parts) { + for (const part of payload.parts) { + if (part.mimeType === "text/plain" && part.body?.data) { + return Buffer.from(part.body.data, "base64url").toString(); + } + } + for (const part of payload.parts) { + const nested = this.decodeMessageBody(part); + if (nested) return nested; + } + } + return ""; + } + + private decodeHtmlBody(payload: any): string { + if (!payload) return ""; + if (payload.body?.data && payload.mimeType === "text/html") { + return Buffer.from(payload.body.data, "base64url").toString(); + } + if (payload.parts) { + for (const part of payload.parts) { + if (part.mimeType === "text/html" && part.body?.data) { + return Buffer.from(part.body.data, "base64url").toString(); + } + } + for (const part of payload.parts) { + const nested = this.decodeHtmlBody(part); + if (nested) return nested; + } + } + return ""; + } + async deleteDraft(email: string, draftId: string): Promise { const gmail = this.getGmailClient(email); await gmail.users.drafts.delete({ userId: "me", id: draftId }); @@ -504,7 +696,8 @@ export class GmailService { userId: "me", requestBody: { id: draftId }, }); - return response.data.message!; + // Gmail API returns the sent Message directly in response.data + return response.data; } async sendMessage( @@ -512,48 +705,85 @@ export class GmailService { to: string[], subject: string, body: string, - options: { cc?: string[]; bcc?: string[]; replyToMessageId?: string; attachments?: string[] } = {}, + options: { + cc?: string[]; + bcc?: string[]; + replyToMessageId?: string; + attachments?: string[]; + replyAll?: boolean; + includeQuote?: boolean; + } = {}, ): Promise { const gmail = this.getGmailClient(email); let inReplyTo: string | undefined; let references: string | undefined; let threadId: string | undefined; + let replyData: Awaited> | undefined; + const originalBody = body; // Keep original reply text for HTML generation + + // Use local variables to avoid parameter reassignment + let recipientList = to; + let subjectLine = subject; + let bodyText = body; - // If replying to a specific message, fetch its headers + // If replying to a specific message, use getMessageForReply for auto-fill if (options.replyToMessageId) { - const msg = await gmail.users.messages.get({ - userId: "me", - id: options.replyToMessageId, - format: "metadata", - metadataHeaders: ["Message-ID", "References"], - }); - const headers = msg.data.payload?.headers || []; - const messageId = headers.find((h: any) => h.name === "Message-ID")?.value; - const existingRefs = headers.find((h: any) => h.name === "References")?.value; + replyData = await this.getMessageForReply(email, options.replyToMessageId); + + // Auto-fill To if not provided + if (!recipientList || recipientList.length === 0 || (recipientList.length === 1 && !recipientList[0])) { + const senderEmail = parseEmailAddress(replyData.from).email; + recipientList = [senderEmail]; + + if (options.replyAll) { + // Add original To + Cc (minus self) to Cc + const allRecipients = [...replyData.to, ...replyData.cc]; + const filtered = filterSelfFromRecipients(allRecipients, email); + options.cc = [...(options.cc || []), ...filtered]; + } + } + + // Auto-fill subject if not provided + if (!subjectLine) { + subjectLine = formatReplySubject(replyData.subject); + } - if (messageId) { - inReplyTo = messageId; - references = existingRefs ? `${existingRefs} ${messageId}` : messageId; + // Append quoted text to plain text body if includeQuote !== false + if (options.includeQuote !== false) { + bodyText = bodyText + formatGmailQuote(replyData.date, replyData.from, replyData.body); } - threadId = msg.data.threadId || undefined; + + inReplyTo = replyData.inReplyTo; + references = replyData.references; + threadId = replyData.threadId; } const hasAttachments = options.attachments && options.attachments.length > 0; + const hasHtmlQuote = replyData && options.includeQuote !== false; const boundary = `boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const altBoundary = `alt_${Date.now()}_${Math.random().toString(36).slice(2)}`; + + // Determine content type based on attachments and HTML quote + let contentType: string; + if (hasAttachments) { + contentType = `multipart/mixed; boundary="${boundary}"`; + } else if (hasHtmlQuote) { + contentType = `multipart/alternative; boundary="${altBoundary}"`; + } else { + contentType = "text/plain; charset=UTF-8"; + } const headers = [ `From: ${email}`, - `To: ${to.join(", ")}`, + `To: ${recipientList.join(", ")}`, options.cc?.length ? `Cc: ${options.cc.join(", ")}` : "", options.bcc?.length ? `Bcc: ${options.bcc.join(", ")}` : "", - `Subject: ${subject}`, + `Subject: ${subjectLine}`, inReplyTo ? `In-Reply-To: ${inReplyTo}` : "", references ? `References: ${references}` : "", "MIME-Version: 1.0", - hasAttachments - ? `Content-Type: multipart/mixed; boundary="${boundary}"` - : "Content-Type: text/plain; charset=UTF-8", + `Content-Type: ${contentType}`, ].filter(Boolean); let emailContent: string; @@ -561,8 +791,30 @@ export class GmailService { if (hasAttachments) { const parts: string[] = []; - // Text body part - parts.push(`--${boundary}\r\n` + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + body); + if (hasHtmlQuote) { + // Multipart/alternative inside multipart/mixed + const htmlBody = formatHtmlReplyBody( + originalBody, + replyData!.date, + replyData!.from, + replyData!.body, + replyData!.htmlBody, + ); + parts.push( + `--${boundary}\r\n` + + `Content-Type: multipart/alternative; boundary="${altBoundary}"\r\n\r\n` + + `--${altBoundary}\r\n` + + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + + bodyText + + `\r\n--${altBoundary}\r\n` + + "Content-Type: text/html; charset=UTF-8\r\n\r\n" + + htmlBody + + `\r\n--${altBoundary}--`, + ); + } else { + // Text body part only + parts.push(`--${boundary}\r\n` + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + bodyText); + } // Attachment parts for (const filePath of options.attachments!) { @@ -581,8 +833,22 @@ export class GmailService { } emailContent = headers.join("\r\n") + "\r\n\r\n" + parts.join("\r\n") + `\r\n--${boundary}--`; + } else if (hasHtmlQuote) { + // Multipart/alternative with text and HTML + const htmlBody = formatHtmlReplyBody( + originalBody, + replyData!.date, + replyData!.from, + replyData!.body, + replyData!.htmlBody, + ); + const parts = [ + `--${altBoundary}\r\n` + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + bodyText, + `--${altBoundary}\r\n` + "Content-Type: text/html; charset=UTF-8\r\n\r\n" + htmlBody, + ]; + emailContent = headers.join("\r\n") + "\r\n\r\n" + parts.join("\r\n") + `\r\n--${altBoundary}--`; } else { - emailContent = headers.join("\r\n") + "\r\n\r\n" + body; + emailContent = headers.join("\r\n") + "\r\n\r\n" + bodyText; } const encodedEmail = Buffer.from(emailContent).toString("base64url"); diff --git a/src/reply-utils.test.ts b/src/reply-utils.test.ts new file mode 100644 index 0000000..2bddb9b --- /dev/null +++ b/src/reply-utils.test.ts @@ -0,0 +1,632 @@ +import { describe, expect, it } from "vitest"; +import { + escapeHtml, + filterSelfFromRecipients, + formatAttributionLine, + formatGmailQuote, + formatHtmlAttributionLine, + formatHtmlGmailQuote, + formatHtmlReplyBody, + formatQuotedBody, + formatReplySubject, + parseEmailAddress, + parseEmailList, + textToHtml, +} from "./reply-utils.js"; + +describe("formatAttributionLine", () => { + it('formats RFC 2822 date with "Name " sender', () => { + // Use a fixed UTC date to avoid timezone issues in tests + const date = "Mon, 6 Jan 2025 18:30:00 +0000"; + const sender = "John Doe "; + + const result = formatAttributionLine(date, sender); + + // The exact time depends on local timezone, so we check the format + // Gmail format: no comma after time, uses narrow non-breaking space before AM/PM + expect(result).toMatch(/^On Mon, Jan 6, 2025 at \d{1,2}:\d{2}\u202F(AM|PM) John Doe wrote:$/); + }); + + it("produces Gmail-style format with AM time", () => { + // Early morning UTC time + const date = "Wed, 15 Mar 2023 08:05:00 +0000"; + const sender = "Jane Smith "; + + const result = formatAttributionLine(date, sender); + + expect(result).toMatch( + /^On \w+, \w+ \d+, \d{4} at \d{1,2}:\d{2}\u202F(AM|PM) Jane Smith wrote:$/, + ); + expect(result).toContain("Jane Smith wrote:"); + }); + + it("produces Gmail-style format with PM time", () => { + // Afternoon UTC time + const date = "Fri, 20 Dec 2024 14:45:00 +0000"; + const sender = "Bob Wilson "; + + const result = formatAttributionLine(date, sender); + + expect(result).toMatch( + /^On \w+, \w+ \d+, \d{4} at \d{1,2}:\d{2}\u202F(AM|PM) Bob Wilson wrote:$/, + ); + expect(result).toContain("Bob Wilson wrote:"); + }); + + it("handles plain email sender without name - wraps in angle brackets", () => { + const date = "Tue, 1 Feb 2022 12:00:00 +0000"; + const sender = "noreply@service.com"; + + const result = formatAttributionLine(date, sender); + + // Gmail wraps plain email in angle brackets + expect(result).toContain(" wrote:"); + }); + + it("pads minutes with leading zero", () => { + const date = "Sun, 10 Jul 2024 09:05:00 +0000"; + const sender = "Test "; + + const result = formatAttributionLine(date, sender); + + // Should have :05 not :5, with narrow non-breaking space before AM/PM + expect(result).toMatch(/:0\d\u202F(AM|PM)/); + }); +}); + +describe("formatQuotedBody", () => { + it('prefixes each line with "> "', () => { + const body = "Hello\nWorld\nTest"; + + const result = formatQuotedBody(body); + + expect(result).toBe("> Hello\n> World\n> Test"); + }); + + it("returns empty string for empty body", () => { + expect(formatQuotedBody("")).toBe(""); + }); + + it("returns empty string for falsy body", () => { + // @ts-expect-error testing falsy values + expect(formatQuotedBody(null)).toBe(""); + // @ts-expect-error testing falsy values + expect(formatQuotedBody(undefined)).toBe(""); + }); + + it("handles single line body", () => { + const body = "Just one line"; + + const result = formatQuotedBody(body); + + expect(result).toBe("> Just one line"); + }); + + it("handles multiple lines with empty lines", () => { + const body = "First line\n\nThird line"; + + const result = formatQuotedBody(body); + + expect(result).toBe("> First line\n> \n> Third line"); + }); + + it("preserves trailing newlines", () => { + const body = "Line one\nLine two\n"; + + const result = formatQuotedBody(body); + + expect(result).toBe("> Line one\n> Line two\n> "); + }); +}); + +describe("formatGmailQuote", () => { + it("combines attribution and quoted body correctly", () => { + const date = "Mon, 6 Jan 2025 10:30:00 +0000"; + const sender = "Alice "; + const body = "Hello there!\nHow are you?"; + + const result = formatGmailQuote(date, sender, body); + + // Should start with two newlines + expect(result.startsWith("\n\n")).toBe(true); + + // Should contain attribution line + expect(result).toContain("Alice wrote:"); + + // Should contain quoted body + expect(result).toContain("> Hello there!"); + expect(result).toContain("> How are you?"); + }); + + it('format starts with "\\n\\n"', () => { + const date = "Tue, 7 Jan 2025 15:00:00 +0000"; + const sender = "Bob "; + const body = "Test message"; + + const result = formatGmailQuote(date, sender, body); + + expect(result.slice(0, 2)).toBe("\n\n"); + }); + + it("has blank line between attribution and quoted body", () => { + const date = "Wed, 8 Jan 2025 09:00:00 +0000"; + const sender = "Carol "; + const body = "Message content"; + + const result = formatGmailQuote(date, sender, body); + + // Gmail format: blank line between attribution and quoted content + expect(result).toMatch(/wrote:\n\n>/); + }); + + it("handles empty body", () => { + const date = "Thu, 9 Jan 2025 12:00:00 +0000"; + const sender = "Dave "; + const body = ""; + + const result = formatGmailQuote(date, sender, body); + + expect(result.startsWith("\n\n")).toBe(true); + expect(result).toContain("wrote:"); + // Empty body should produce empty quoted section with blank line + expect(result.endsWith("wrote:\n\n")).toBe(true); + }); +}); + +describe("formatReplySubject", () => { + it('adds "Re: " prefix to plain subject', () => { + const subject = "Meeting tomorrow"; + + const result = formatReplySubject(subject); + + expect(result).toBe("Re: Meeting tomorrow"); + }); + + it('does not double-add "Re: " prefix', () => { + const subject = "Re: Meeting tomorrow"; + + const result = formatReplySubject(subject); + + expect(result).toBe("Re: Meeting tomorrow"); + }); + + it('handles "RE: " (uppercase)', () => { + const subject = "RE: Important update"; + + const result = formatReplySubject(subject); + + expect(result).toBe("RE: Important update"); + }); + + it('handles "re: " (lowercase)', () => { + const subject = "re: Quick question"; + + const result = formatReplySubject(subject); + + expect(result).toBe("re: Quick question"); + }); + + it('handles mixed case "Re:" variations', () => { + expect(formatReplySubject("Re:Hello")).toBe("Re:Hello"); + expect(formatReplySubject("rE: Test")).toBe("rE: Test"); + }); + + it("trims whitespace from subject", () => { + const subject = " Important message "; + + const result = formatReplySubject(subject); + + expect(result).toBe("Re: Important message"); + }); + + it("handles empty subject", () => { + const result = formatReplySubject(""); + + expect(result).toBe("Re: "); + }); +}); + +describe("parseEmailAddress", () => { + it('parses "Name " format', () => { + const header = "John Doe "; + + const result = parseEmailAddress(header); + + expect(result).toEqual({ + name: "John Doe", + email: "john@example.com", + }); + }); + + it("parses plain email format", () => { + const header = "john@example.com"; + + const result = parseEmailAddress(header); + + expect(result).toEqual({ + name: "", + email: "john@example.com", + }); + }); + + it("handles spaces in name", () => { + const header = "Mary Jane Watson "; + + const result = parseEmailAddress(header); + + expect(result).toEqual({ + name: "Mary Jane Watson", + email: "mary.jane@example.com", + }); + }); + + it("trims whitespace from header", () => { + const header = " Jane Doe "; + + const result = parseEmailAddress(header); + + expect(result).toEqual({ + name: "Jane Doe", + email: "jane@test.com", + }); + }); + + it("handles extra spaces around angle brackets", () => { + const header = "Bob Smith "; + + const result = parseEmailAddress(header); + + expect(result).toEqual({ + name: "Bob Smith", + email: "bob@company.org", + }); + }); + + it("handles quoted display name", () => { + const header = '"Last, First" '; + + const result = parseEmailAddress(header); + + expect(result).toEqual({ + name: '"Last, First"', + email: "first.last@example.com", + }); + }); +}); + +describe("parseEmailList", () => { + it("parses comma-separated emails", () => { + const header = "alice@example.com, bob@test.com, carol@mail.org"; + + const result = parseEmailList(header); + + expect(result).toEqual(["alice@example.com", "bob@test.com", "carol@mail.org"]); + }); + + it("parses mixed formats (plain + name)", () => { + const header = "Alice Smith , bob@test.com, Carol Jones "; + + const result = parseEmailList(header); + + expect(result).toEqual(["alice@example.com", "bob@test.com", "carol@mail.org"]); + }); + + it("parses single email", () => { + const header = "only@one.com"; + + const result = parseEmailList(header); + + expect(result).toEqual(["only@one.com"]); + }); + + it("parses single named email", () => { + const header = "Only Person "; + + const result = parseEmailList(header); + + expect(result).toEqual(["only@one.com"]); + }); + + it("returns empty array for empty string", () => { + expect(parseEmailList("")).toEqual([]); + }); + + it("returns empty array for whitespace-only string", () => { + expect(parseEmailList(" ")).toEqual([]); + }); + + it("handles extra whitespace around commas", () => { + const header = "a@example.com , b@example.com , c@example.com"; + + const result = parseEmailList(header); + + expect(result).toEqual(["a@example.com", "b@example.com", "c@example.com"]); + }); +}); + +describe("filterSelfFromRecipients", () => { + it("removes self from list", () => { + const recipients = ["alice@example.com", "bob@test.com", "carol@mail.org"]; + const selfEmail = "bob@test.com"; + + const result = filterSelfFromRecipients(recipients, selfEmail); + + expect(result).toEqual(["alice@example.com", "carol@mail.org"]); + }); + + it("uses case-insensitive matching", () => { + const recipients = ["Alice@Example.COM", "bob@test.com"]; + const selfEmail = "alice@example.com"; + + const result = filterSelfFromRecipients(recipients, selfEmail); + + expect(result).toEqual(["bob@test.com"]); + }); + + it("handles self not in list (no change)", () => { + const recipients = ["alice@example.com", "bob@test.com"]; + const selfEmail = "other@somewhere.com"; + + const result = filterSelfFromRecipients(recipients, selfEmail); + + expect(result).toEqual(["alice@example.com", "bob@test.com"]); + }); + + it("returns empty array when only self in list", () => { + const recipients = ["me@example.com"]; + const selfEmail = "me@example.com"; + + const result = filterSelfFromRecipients(recipients, selfEmail); + + expect(result).toEqual([]); + }); + + it("handles empty recipient list", () => { + const recipients: string[] = []; + const selfEmail = "me@example.com"; + + const result = filterSelfFromRecipients(recipients, selfEmail); + + expect(result).toEqual([]); + }); + + it("removes multiple occurrences of self", () => { + const recipients = ["me@example.com", "other@test.com", "ME@EXAMPLE.COM"]; + const selfEmail = "me@example.com"; + + const result = filterSelfFromRecipients(recipients, selfEmail); + + expect(result).toEqual(["other@test.com"]); + }); +}); + +describe("escapeHtml", () => { + it("escapes ampersand", () => { + expect(escapeHtml("Tom & Jerry")).toBe("Tom & Jerry"); + }); + + it("escapes less than sign", () => { + expect(escapeHtml("a < b")).toBe("a < b"); + }); + + it("escapes greater than sign", () => { + expect(escapeHtml("a > b")).toBe("a > b"); + }); + + it("escapes double quotes", () => { + expect(escapeHtml('say "hello"')).toBe("say "hello""); + }); + + it("escapes all special characters together", () => { + const input = '
Tom & Jerry
'; + const expected = "<div class="test">Tom & Jerry</div>"; + expect(escapeHtml(input)).toBe(expected); + }); + + it("returns text unchanged when no special chars", () => { + const input = "Hello World! This is a test."; + expect(escapeHtml(input)).toBe(input); + }); + + it("returns empty string for empty input", () => { + expect(escapeHtml("")).toBe(""); + }); +}); + +describe("textToHtml", () => { + it("converts newlines to
", () => { + const input = "Line one\nLine two\nLine three"; + const expected = "Line one
Line two
Line three"; + expect(textToHtml(input)).toBe(expected); + }); + + it("escapes special chars while converting", () => { + const input = "Hello \nTom & Jerry"; + const expected = "Hello <World>
Tom & Jerry"; + expect(textToHtml(input)).toBe(expected); + }); + + it("handles text with no newlines", () => { + const input = "Just one line"; + expect(textToHtml(input)).toBe("Just one line"); + }); + + it("handles empty string", () => { + expect(textToHtml("")).toBe(""); + }); + + it("handles multiple consecutive newlines", () => { + const input = "Line one\n\n\nLine two"; + const expected = "Line one


Line two"; + expect(textToHtml(input)).toBe(expected); + }); +}); + +describe("formatHtmlAttributionLine", () => { + it("produces HTML with mailto link", () => { + const date = "Mon, 6 Jan 2025 18:30:00 +0000"; + const sender = "John Doe "; + + const result = formatHtmlAttributionLine(date, sender); + + expect(result).toContain('john@example.com'); + expect(result).toContain("wrote:"); + }); + + it("handles plain email (no name)", () => { + const date = "Tue, 7 Jan 2025 10:00:00 +0000"; + const sender = "noreply@service.com"; + + const result = formatHtmlAttributionLine(date, sender); + + expect(result).toContain('noreply@service.com'); + // Plain email should be wrapped in escaped angle brackets + expect(result).toContain('<noreply@service.com>'); + expect(result).toContain("wrote:"); + }); + + it('handles "Name " format', () => { + const date = "Wed, 8 Jan 2025 14:00:00 +0000"; + const sender = "Alice Smith "; + + const result = formatHtmlAttributionLine(date, sender); + + expect(result).toContain("Alice Smith"); + expect(result).toContain('<alice@example.com>'); + expect(result).toContain("wrote:"); + }); + + it("escapes special characters in name", () => { + const date = "Thu, 9 Jan 2025 09:00:00 +0000"; + const sender = "Tom & Jerry "; + + const result = formatHtmlAttributionLine(date, sender); + + expect(result).toContain("Tom & Jerry"); + expect(result).toContain('tom@example.com'); + }); +}); + +describe("formatHtmlGmailQuote", () => { + it("escapes plain text body", () => { + const date = "Mon, 6 Jan 2025 18:30:00 +0000"; + const sender = "John Doe "; + const body = "Hello & everyone"; + + const result = formatHtmlGmailQuote(date, sender, body); + + expect(result).toContain("Hello <World> & everyone"); + }); + + it("uses HTML body directly without escaping", () => { + const date = "Tue, 7 Jan 2025 10:00:00 +0000"; + const sender = "Alice "; + const body = "Plain text fallback"; + const htmlBody = "
Already formatted HTML
"; + + const result = formatHtmlGmailQuote(date, sender, body, htmlBody); + + expect(result).toContain("
Already formatted HTML
"); + expect(result).not.toContain("Plain text fallback"); + }); + + it("includes blockquote CSS styles", () => { + const date = "Wed, 8 Jan 2025 14:00:00 +0000"; + const sender = "Bob "; + const body = "Test message"; + + const result = formatHtmlGmailQuote(date, sender, body); + + expect(result).toContain( + 'style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"', + ); + }); + + it("includes gmail_quote class", () => { + const date = "Thu, 9 Jan 2025 09:00:00 +0000"; + const sender = "Carol "; + const body = "Another test"; + + const result = formatHtmlGmailQuote(date, sender, body); + + expect(result).toContain('class="gmail_quote gmail_quote_container"'); + expect(result).toContain('class="gmail_attr"'); + expect(result).toContain('
{ + const date = "Fri, 10 Jan 2025 12:00:00 +0000"; + const sender = "Dave "; + const body = "Message content"; + + const result = formatHtmlGmailQuote(date, sender, body); + + expect(result).toContain("wrote:
"); + }); +}); + +describe("formatHtmlReplyBody", () => { + it("formats reply with plain text only", () => { + const replyText = "Thanks for your message!"; + const date = "Mon, 6 Jan 2025 18:30:00 +0000"; + const sender = "John Doe "; + const originalBody = "Original message here"; + + const result = formatHtmlReplyBody(replyText, date, sender, originalBody); + + expect(result).toContain("Thanks for your message!"); + expect(result).toContain("Original message here"); + expect(result).toContain("wrote:"); + }); + + it("uses HTML body for nested quotes", () => { + const replyText = "My reply"; + const date = "Tue, 7 Jan 2025 10:00:00 +0000"; + const sender = "Alice "; + const originalBody = "Plain text fallback"; + const originalHtmlBody = "
HTML with
nested quote
"; + + const result = formatHtmlReplyBody(replyText, date, sender, originalBody, originalHtmlBody); + + expect(result).toContain("My reply"); + expect(result).toContain("
HTML with
nested quote
"); + expect(result).not.toContain("Plain text fallback"); + }); + + it("includes correct structure with reply text and quote", () => { + const replyText = "Hello!"; + const date = "Wed, 8 Jan 2025 14:00:00 +0000"; + const sender = "Bob "; + const originalBody = "Hi there"; + + const result = formatHtmlReplyBody(replyText, date, sender, originalBody); + + // Check overall structure + expect(result).toMatch(/^
/); + expect(result).toMatch(/<\/div>$/); + expect(result).toContain("
Hello!
"); + expect(result).toContain('
'); + }); + + it("escapes special chars in reply text", () => { + const replyText = "Check this: