Compare commits
44 Commits
finetune
...
FEAT-readm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a0a485d9 | ||
|
|
cebfb0d589 | ||
|
|
c22d616595 | ||
|
|
cfcac8607c | ||
|
|
8ad47f1d27 | ||
| 38c7e48a1d | |||
|
|
6817014b7f | ||
| dade7f1fb8 | |||
|
|
632b0029be | ||
| 549f2b0949 | |||
| c1f03a7cbe | |||
|
|
b767a1f09c | ||
| 76d4b490bf | |||
|
|
31618b1acf | ||
| 087839d2bf | |||
| b9337d1b0c | |||
|
|
ee12a44ec1 | ||
| dbfa913e5e | |||
|
|
72ec89a1c6 | ||
|
|
a91c8c0009 | ||
|
|
8874b32334 | ||
|
|
f3cf4a51bd | ||
|
|
065645983a | ||
|
|
98585b52ad | ||
|
|
ea62edbc21 | ||
|
|
21b4e656f6 | ||
|
|
fc3223a119 | ||
|
|
9adc9cc3e0 | ||
|
|
7a02c49bd2 | ||
|
|
1cff926c15 | ||
|
|
809da541dc | ||
|
|
b44118bd2f | ||
|
|
1d86280e8c | ||
|
|
a22a43a5ab | ||
|
|
9d465d5fca | ||
|
|
5ca368f15e | ||
|
|
dc8624592d | ||
|
|
3a3a971feb | ||
|
|
8fb4fe7ce5 | ||
|
|
a0e1b43360 | ||
|
|
410ee0656f | ||
|
|
94b07531d0 | ||
|
|
1094f9c972 | ||
|
|
68e48b2345 |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,12 +1,12 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
# OAuth secrets
|
# OAuth secrets (inside accountsAndCredentials/, example files are NOT ignored)
|
||||||
credentials.json
|
accountsAndCredentials/credentials.json
|
||||||
credentials.*
|
accountsAndCredentials/accounts.json
|
||||||
token*.json
|
accountsAndCredentials/token*.json
|
||||||
accounts.json
|
accountsAndCredentials/token.json.bak
|
||||||
|
!accountsAndCredentials/*.example.json
|
||||||
|
|
||||||
# Runtime output
|
# Runtime output
|
||||||
summary.json
|
mailSummaries/
|
||||||
summary-secondary.json
|
|
||||||
|
|||||||
350
README.md
350
README.md
@@ -1,272 +1,188 @@
|
|||||||
# Declawed: A Configurable, Promptable AI Mail Assisty Kitty
|
<p align="center">
|
||||||
|
<img src="assets/logo.png" alt="deClawed" width="120" />
|
||||||
|
</p>
|
||||||
|
|
||||||
A local Model Context Protocol (MCP) server and LLM integration platform. Built to connect to LLM APIs - be itClaude Desktop, *others*... or locally hosted models. Executes your prompts to manage your mail, while you... watch Fellini films, solve climate change, sip Mai Tais, or.... whatever.
|
# deClawed
|
||||||
|
|
||||||
Ssssimple. Siamsese, if you please.... Eats the blue-plate-crustacean for breakfast.
|
**LLM-powered email triage and workflow automation via the Model Context Protocol**
|
||||||
|
|
||||||
Infinitely mod-able. Dead simple. Privacy centric.
|
deClawed is an MCP server that connects your Gmail inbox to any LLM-capable client (such as Claude Desktop). It classifies incoming emails, automates routine actions (delete, archive, summarize), and integrates with Google Sheets and Calendar to keep your job search—or any high-volume email workflow—organized and actionable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Scope
|
## Getting Started
|
||||||
|
|
||||||
## Current implementation contemplates: Claude Desktop + Custom, Local Model Context Protocol Server + Commercial SMTP Server (MX'config’d properly) + your DNS-config’d XXX.YYY
|
### Requirements
|
||||||
|
|
||||||
---
|
| Dependency | Version |
|
||||||
|
|------------|---------|
|
||||||
|
| Node.js | 16+ |
|
||||||
|
| npm | 8+ |
|
||||||
|
| Google Cloud Project | With Gmail API enabled |
|
||||||
|
| MCP Client | Claude Desktop (or any MCP-compatible client) |
|
||||||
|
|
||||||
### 1. Install: Node.js
|
### Installation
|
||||||
|
|
||||||
Node.js v16 or higher must be installed.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node --version
|
|
||||||
npm --version
|
|
||||||
```
|
|
||||||
|
|
||||||
If not installed, download from [nodejs.org](https://nodejs.org/).
|
|
||||||
|
|
||||||
### 2. Initialize the Project
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir assistant
|
|
||||||
cd assistant
|
|
||||||
npm init -y
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @modelcontextprotocol/sdk zod@3 googleapis
|
|
||||||
npm install -D @types/node typescript
|
|
||||||
```
|
|
||||||
|
|
||||||
Create the source directory and entry file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir src
|
|
||||||
touch src/index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Configure the Project
|
|
||||||
|
|
||||||
#### 4a. Update `package.json`
|
|
||||||
|
|
||||||
Set the module type, binary entry, and build scripts:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "module",
|
|
||||||
"bin": {
|
|
||||||
"assistant": "./build/index.js"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc && chmod 755 build/index.js",
|
|
||||||
"auth": "npm run build && node build/auth.js"
|
|
||||||
},
|
|
||||||
"files": ["build"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4b. Create `tsconfig.json` in the project root
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "Node16",
|
|
||||||
"moduleResolution": "Node16",
|
|
||||||
"outDir": "./build",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Write the Server Code
|
|
||||||
|
|
||||||
The server source lives in `src/index.ts`. It registers three tools and one prompt with the MCP server:
|
|
||||||
|
|
||||||
- **`fetch_new_emails`** -- fetches unread Gmail messages
|
|
||||||
- **`delete_emails`** -- trashes messages by ID
|
|
||||||
- **`append_to_summary`** -- logs classified emails to `summary.json`
|
|
||||||
- **`review_emails`** (prompt) -- feeds Claude the classification instructions
|
|
||||||
|
|
||||||
The auth helper lives in `src/auth.ts`, used only for the one-time OAuth setup.
|
|
||||||
|
|
||||||
### 6. Set Up Google Cloud Credentials
|
|
||||||
|
|
||||||
#### 6a. Create a Google Cloud Project
|
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
||||||
2. Sign in with the Google account that owns the target Gmail
|
|
||||||
3. Click the project dropdown (top-left) and select **New Project**
|
|
||||||
4. Name it (e.g., `assistant-mcp`) and click **Create**
|
|
||||||
5. Select the new project from the dropdown
|
|
||||||
|
|
||||||
#### 6b. Enable the Gmail API
|
|
||||||
|
|
||||||
1. Go to **APIs & Services > Library** ([direct link](https://console.cloud.google.com/apis/library))
|
|
||||||
2. Search for **Gmail API**
|
|
||||||
3. Click it, then click **Enable**
|
|
||||||
|
|
||||||
#### 6c. Configure the OAuth Consent Screen
|
|
||||||
|
|
||||||
1. Go to **Google Auth Platform > Branding** (or **APIs & Services > OAuth consent screen**)
|
|
||||||
2. Set user type to **External**, click **Create**
|
|
||||||
3. Fill in app name, support email, and developer contact email
|
|
||||||
4. Save and continue
|
|
||||||
|
|
||||||
#### 6d. Add the Gmail Scope
|
|
||||||
|
|
||||||
1. Go to **Google Auth Platform > Data Access** (or the Scopes page)
|
|
||||||
2. Click **Add or remove scopes**
|
|
||||||
3. Add: `https://www.googleapis.com/auth/gmail.modify`
|
|
||||||
4. Save
|
|
||||||
|
|
||||||
#### 6e. Add Yourself as a Test User
|
|
||||||
|
|
||||||
1. Go to **Google Auth Platform > Audience**
|
|
||||||
2. Add your Gmail address as a test user
|
|
||||||
|
|
||||||
#### 6f. Create OAuth Client Credentials
|
|
||||||
|
|
||||||
1. Go to **Google Auth Platform > Clients** (or **APIs & Services > Credentials**)
|
|
||||||
2. Click **Create Client** (or **+ Create Credentials > OAuth client ID**)
|
|
||||||
3. Application type: **Desktop app**
|
|
||||||
4. Name it anything (e.g., `Assistant MCP Desktop`)
|
|
||||||
5. Click **Create**
|
|
||||||
6. **Download the JSON** file
|
|
||||||
7. Rename it to `credentials.json`
|
|
||||||
8. Move it to the project root: `/Users/kjannette/assistant/credentials.json`
|
|
||||||
|
|
||||||
### 7. Authorize Your Gmail Account
|
|
||||||
|
|
||||||
Build the project and run the auth script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run auth
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Print a URL -- open it in your browser
|
|
||||||
2. Sign in with your Google account and click **Allow**
|
|
||||||
3. You'll land on a "localhost refused to connect" page (this is normal)
|
|
||||||
4. Copy the **entire URL** from the browser address bar
|
|
||||||
5. Paste it into the terminal prompt
|
|
||||||
6. The script extracts the auth code and saves `token.json`
|
|
||||||
|
|
||||||
You only need to do this once. The token auto-refreshes.
|
|
||||||
|
|
||||||
### 8. Build the Server
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone https://git.sjdev.online/kjannette/deClawed-Assity-Kitty
|
||||||
|
cd deClawed-Assity-Kitty
|
||||||
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
This compiles `src/*.ts` into `build/*.js`.
|
### Google Cloud Setup
|
||||||
|
|
||||||
### 9. Write the Classification Prompt
|
1. Create a project at [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
2. Enable the **Gmail API** (and optionally **Sheets API** and **Calendar API**)
|
||||||
|
3. Configure OAuth consent screen (External, add your email as a test user)
|
||||||
|
4. Create OAuth credentials (Desktop app) and download `credentials.json`
|
||||||
|
5. Place `credentials.json` in the `accountsAndCredentials/` directory
|
||||||
|
|
||||||
Create a file called `classify-emails.txt` in the project root. This file contains the plain-text instructions that tell Claude how to classify your emails.
|
### Account Configuration
|
||||||
|
|
||||||
**Tips for writing the prompt:**
|
Create `accountsAndCredentials/accounts.json`:
|
||||||
- Use clear, explicit category definitions with example language for each
|
|
||||||
- Handle ambiguous cases (e.g., "If an email both acknowledges receipt AND requests action, classify it as B")
|
|
||||||
- Define the exact actions to take for each category (delete, summarize, etc.)
|
|
||||||
- Specify what fields to include in summaries
|
|
||||||
- Keep it in plain text -- no JSON or special formatting needed
|
|
||||||
- The file is loaded at runtime, so you can edit it without rebuilding the server
|
|
||||||
|
|
||||||
**File location:** Must be at the project root as `classify-emails.txt`.
|
```json
|
||||||
|
{
|
||||||
### 10. Configure Claude Desktop
|
"work": {
|
||||||
|
"label": "you@example.com",
|
||||||
Edit the Claude Desktop config file:
|
"tokenFile": "token.json",
|
||||||
|
"spreadsheetId": "YOUR_GOOGLE_SHEET_ID",
|
||||||
```bash
|
"calendarId": "primary"
|
||||||
code ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Add the `assistant` server to the `mcpServers` object:
|
### Authorize Gmail Access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run auth # Authorize default account
|
||||||
|
npm run auth -- secondary # Authorize additional accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow the browser prompts to complete OAuth. Tokens are saved locally and auto-refresh.
|
||||||
|
|
||||||
|
### Connect to Claude Desktop
|
||||||
|
|
||||||
|
Add the server to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"assistant": {
|
"assistant": {
|
||||||
"command": "/ABSOLUTE/PATH/TO/node",
|
"command": "/path/to/node",
|
||||||
"args": [
|
"args": ["/path/to/deClawed-Assity-Kitty/build/index.js"]
|
||||||
"/Users/kjannette/assistant/build/index.js"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `/ABSOLUTE/PATH/TO/node` with the output of `which node`.
|
Restart Claude Desktop. The server appears under **Connectors**.
|
||||||
|
|
||||||
### 11. Restart Claude Desktop
|
|
||||||
|
|
||||||
Fully quit Claude Desktop (**Cmd+Q**, not just close the window) and reopen it. The `assistant` server should now appear under **Connectors** in the chat input.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage Guide
|
## Usage
|
||||||
|
|
||||||
### Prompt Loader
|
### Basic Workflow
|
||||||
|
|
||||||
The server reads `classify-emails.txt` from the project root at runtime. To change classification behavior, edit that file directly -- no rebuild required. The updated instructions take effect on the next tool call.
|
In Claude Desktop, invoke the email review workflow:
|
||||||
|
|
||||||
### MCP Prompt: `review_emails`
|
```
|
||||||
|
Review my inbox
|
||||||
|
```
|
||||||
|
|
||||||
A registered MCP prompt available in Claude Desktop's Connectors menu. When invoked, it feeds Claude the full contents of `classify-emails.txt` as a user message, giving Claude all the classification criteria before it calls any tools. This is the recommended way to trigger the workflow -- it ensures Claude has the complete instructions every time.
|
The server fetches unread emails, classifies them using your configured prompts, and executes the appropriate actions.
|
||||||
|
|
||||||
### `fetch_new_emails` Enrichment
|
### Classification Categories
|
||||||
|
|
||||||
Every time `fetch_new_emails` is called, the classification instructions from `classify-emails.txt` are appended to the response alongside the email data. This means Claude always sees the rules with the data, even if the `review_emails` prompt was not explicitly invoked. Belt and suspenders.
|
| Category | Description | Default Action |
|
||||||
|
|----------|-------------|----------------|
|
||||||
|
| **A** | Acknowledgements, auto-replies | Delete |
|
||||||
|
| **B** | Advancement (interview requests, next steps) | Log + Calendar event |
|
||||||
|
| **C** | Rejections | Delete |
|
||||||
|
| **D** | Other/uncategorized | Log for review |
|
||||||
|
|
||||||
### Running the Workflow
|
### Customizing Prompts
|
||||||
|
|
||||||
1. Open Claude Desktop
|
Edit the plain-text files in `src/prompts/` to adjust classification rules and actions:
|
||||||
2. Type: **"Review my inbox"** (or invoke the `review_emails` prompt from Connectors)
|
|
||||||
3. Claude will:
|
|
||||||
- Call `fetch_new_emails` to retrieve unread messages
|
|
||||||
- Classify each email as A, B, C, or D using the prompt instructions
|
|
||||||
- Call `delete_emails` for categories A and C
|
|
||||||
- Call `append_to_summary` for categories B and D
|
|
||||||
4. Results are displayed in the chat and saved to `summary.json`
|
|
||||||
|
|
||||||
### Key Commands
|
- `classify-emails.txt` — Defines category criteria
|
||||||
|
- `take-action-on-emails.txt` — Specifies actions per category
|
||||||
|
|
||||||
|
Changes take effect immediately without rebuilding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Tools Reference
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `fetch_new_emails` | Fetch unread emails with classification instructions appended |
|
||||||
|
| `delete_emails` | Move specified message IDs to trash |
|
||||||
|
| `star_emails` | Star messages and mark as read |
|
||||||
|
| `append_to_summary` | Log email metadata to local JSON (auto-purges after 30 days) |
|
||||||
|
| `log_recruiter_contact` | Append or update a row in Google Sheets |
|
||||||
|
| `create_calendar_event` | Create a Google Calendar event (skips past dates) |
|
||||||
|
|
||||||
|
### Example: Fetch Emails
|
||||||
|
|
||||||
|
```
|
||||||
|
fetch_new_emails(account: "work", maxResults: 50)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Create Calendar Event
|
||||||
|
|
||||||
|
```
|
||||||
|
create_calendar_event(
|
||||||
|
account: "work",
|
||||||
|
title: "[Interview] Acme Corp - Senior Engineer",
|
||||||
|
startDateTime: "2026-09-01T14:00:00-04:00",
|
||||||
|
durationMinutes: 60,
|
||||||
|
location: "https://zoom.us/j/123456789"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `npm run build` | Recompile after editing `src/index.ts` |
|
| `npm run build` | Compile TypeScript to `build/` |
|
||||||
| `npm run auth` | Re-authorize Gmail (only if `token.json` deleted/expired) |
|
| `npm run auth` | Authorize the default account |
|
||||||
| Cmd+Q Claude Desktop, reopen | Pick up server changes after a rebuild |
|
| `npm run auth -- <name>` | Authorize a named account |
|
||||||
|
| `npm test` | Run test suite |
|
||||||
|
| `npm run test:watch` | Run tests in watch mode |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
assistant/
|
deClawed-Assity-Kitty/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── index.ts # MCP server source (tools + prompt)
|
│ ├── index.ts # Entry point
|
||||||
│ └── auth.ts # One-time OAuth setup script
|
│ ├── McpServer.ts # MCP server instance
|
||||||
├── build/
|
│ ├── auth.ts # OAuth setup script
|
||||||
│ ├── index.js # Compiled server (Claude Desktop runs this)
|
│ ├── loaders/ # Config and prompt loaders
|
||||||
│ └── auth.js # Compiled auth script
|
│ ├── prompts/ # Classification and action prompts
|
||||||
├── classify-emails.txt # Classification prompt (plain text, edit anytime)
|
│ └── tools/ # MCP tool implementations
|
||||||
├── credentials.json # Google OAuth client credentials (from Cloud Console)
|
├── accountsAndCredentials/ # OAuth credentials and tokens (gitignored)
|
||||||
├── token.json # Gmail access/refresh token (auto-generated)
|
├── mailSummaries/ # Local email logs (gitignored)
|
||||||
├── summary.json # Output file where B/D emails are logged
|
├── test/ # Unit and integration tests
|
||||||
├── package.json # Project config and scripts
|
└── build/ # Compiled output
|
||||||
├── tsconfig.json # TypeScript compiler config
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- All credentials and tokens are stored locally and excluded from version control
|
||||||
|
- OAuth tokens auto-refresh; re-authorization is only needed if revoked
|
||||||
|
- The server runs locally via stdio—no network exposure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
ISC
|
||||||
|
|||||||
12
accountsAndCredentials/accounts.example.json
Normal file
12
accountsAndCredentials/accounts.example.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"work": {
|
||||||
|
"label": "",
|
||||||
|
"tokenFile": "token.json",
|
||||||
|
"spreadsheetId": "",
|
||||||
|
"calendarId": "primary"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"label": "",
|
||||||
|
"tokenFile": "token-secondary.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
accountsAndCredentials/credentials.example.json
Normal file
11
accountsAndCredentials/credentials.example.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"installed": {
|
||||||
|
"client_id": "",
|
||||||
|
"project_id": "",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
|
"client_secret": "",
|
||||||
|
"redirect_uris": ["http://localhost"]
|
||||||
|
}
|
||||||
|
}
|
||||||
7
accountsAndCredentials/token.example.json
Normal file
7
accountsAndCredentials/token.example.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"access_token": "",
|
||||||
|
"refresh_token": "",
|
||||||
|
"scope": "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/calendar.events",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expiry_date": 0
|
||||||
|
}
|
||||||
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
1466
package-lock.json
generated
1466
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,9 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && chmod 755 build/index.js",
|
"build": "tsc && chmod 755 build/index.js",
|
||||||
"auth": "npm run build && node build/auth.js"
|
"auth": "npm run build && node build/auth.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"build"
|
"build"
|
||||||
@@ -17,12 +19,13 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"googleapis": "^171.4.0",
|
"googleapis": "^171.4.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/McpServer.ts
Normal file
6
src/McpServer.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
|
||||||
|
export const server = new McpServer({
|
||||||
|
name: "assistant",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
@@ -5,8 +5,9 @@ import readline from "readline";
|
|||||||
|
|
||||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||||
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
||||||
const CREDENTIALS_PATH = path.join(PROJECT_ROOT, "credentials.json");
|
const SECRETS_DIR = path.join(PROJECT_ROOT, "accountsAndCredentials");
|
||||||
const ACCOUNTS_PATH = path.join(PROJECT_ROOT, "accounts.json");
|
const CREDENTIALS_PATH = path.join(SECRETS_DIR, "credentials.json");
|
||||||
|
const ACCOUNTS_PATH = path.join(SECRETS_DIR, "accounts.json");
|
||||||
|
|
||||||
const SCOPES = [
|
const SCOPES = [
|
||||||
"https://www.googleapis.com/auth/gmail.modify",
|
"https://www.googleapis.com/auth/gmail.modify",
|
||||||
@@ -26,7 +27,7 @@ function resolveTokenPath(accountKey: string): string {
|
|||||||
`Unknown account "${accountKey}". Available: ${available}`
|
`Unknown account "${accountKey}". Available: ${available}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return path.join(PROJECT_ROOT, acct.tokenFile);
|
return path.join(SECRETS_DIR, acct.tokenFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authorize(): Promise<void> {
|
async function authorize(): Promise<void> {
|
||||||
@@ -44,7 +45,7 @@ async function authorize(): Promise<void> {
|
|||||||
"1. Go to https://console.cloud.google.com/\n" +
|
"1. Go to https://console.cloud.google.com/\n" +
|
||||||
"2. Create a project and enable the Gmail, Sheets, and Calendar APIs\n" +
|
"2. Create a project and enable the Gmail, Sheets, and Calendar APIs\n" +
|
||||||
"3. Create OAuth 2.0 credentials (Desktop app type)\n" +
|
"3. Create OAuth 2.0 credentials (Desktop app type)\n" +
|
||||||
"4. Download the JSON and save it as credentials.json in the project root\n"
|
"4. Download the JSON and save it as credentials.json in the accountsAndCredentials/ folder\n"
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
815
src/index.ts
815
src/index.ts
@@ -1,815 +1,18 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
#!/usr/bin/env node
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { z } from "zod";
|
import { server } from "./McpServer.js";
|
||||||
import { google, gmail_v1, sheets_v4, calendar_v3 } from "googleapis";
|
|
||||||
import { OAuth2Client } from "google-auth-library";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// Side-effect imports: each module registers its prompts/tools on the server
|
||||||
// Paths — resolved relative to the compiled build/ directory, up one level
|
import "./prompt-controller-service/prompt-controller-service.js";
|
||||||
// to the project root where credentials.json, token.json, and summary.json live.
|
import "./tools/tools-email.js";
|
||||||
// ---------------------------------------------------------------------------
|
import "./tools/tools-spreadsheet.js";
|
||||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
import "./tools/tools-calendar.js";
|
||||||
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
|
||||||
const CREDENTIALS_PATH = path.join(PROJECT_ROOT, "credentials.json");
|
|
||||||
const ACCOUNTS_PATH = path.join(PROJECT_ROOT, "accounts.json");
|
|
||||||
const CLASSIFY_PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt");
|
|
||||||
const ACTION_PROMPT_PATH = path.join(
|
|
||||||
PROJECT_ROOT, "src", "prompts", "take-action-on-emails.txt"
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const main = async (): Promise<void> => {
|
||||||
// Account configuration
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
interface AccountConfig {
|
|
||||||
label: string;
|
|
||||||
tokenFile: string;
|
|
||||||
spreadsheetId?: string;
|
|
||||||
calendarId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AccountsMap {
|
|
||||||
[key: string]: AccountConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadAccounts(): AccountsMap {
|
|
||||||
if (!fs.existsSync(ACCOUNTS_PATH)) {
|
|
||||||
throw new Error(`Missing accounts.json at ${ACCOUNTS_PATH}.`);
|
|
||||||
}
|
|
||||||
return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTokenPath(account: string): string {
|
|
||||||
const accounts = loadAccounts();
|
|
||||||
const acct = accounts[account];
|
|
||||||
if (!acct) {
|
|
||||||
const available = Object.keys(accounts).join(", ");
|
|
||||||
throw new Error(
|
|
||||||
`Unknown account "${account}". Available accounts: ${available}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return path.join(PROJECT_ROOT, acct.tokenFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSummaryPath(account: string): string {
|
|
||||||
if (account === "work") {
|
|
||||||
return path.join(PROJECT_ROOT, "summary.json");
|
|
||||||
}
|
|
||||||
return path.join(PROJECT_ROOT, `summary-${account}.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALID_ACCOUNTS = ["work", "secondary"] as const;
|
|
||||||
const accounts = loadAccounts();
|
|
||||||
const accountDescription = VALID_ACCOUNTS
|
|
||||||
.map((key) => `"${key}" (${accounts[key]?.label ?? key})`)
|
|
||||||
.join(" or ");
|
|
||||||
const accountSchema = z
|
|
||||||
.enum(VALID_ACCOUNTS)
|
|
||||||
.describe(`Which email account to use: ${accountDescription}`);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Prompt loaders
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function loadPromptFile(filePath: string, label: string): string {
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
console.error(`Warning: ${filePath} not found. ${label} will be missing.`);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return fs.readFileSync(filePath, "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadClassificationPrompt(): string {
|
|
||||||
return loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions");
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadActionPrompt(): string {
|
|
||||||
return loadPromptFile(ACTION_PROMPT_PATH, "Action instructions");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Auth helpers — generic OAuth2 client, then service-specific factories
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function getOAuth2Client(account: string): OAuth2Client {
|
|
||||||
const tokenPath = getTokenPath(account);
|
|
||||||
|
|
||||||
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
|
||||||
throw new Error(
|
|
||||||
`Missing credentials.json at ${CREDENTIALS_PATH}. Run "npm run auth" first.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(tokenPath)) {
|
|
||||||
throw new Error(
|
|
||||||
`Missing token file at ${tokenPath} for account "${account}". Run "npm run auth" first.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
|
|
||||||
const { client_id, client_secret, redirect_uris } =
|
|
||||||
credentials.installed || credentials.web;
|
|
||||||
|
|
||||||
const oAuth2Client = new google.auth.OAuth2(
|
|
||||||
client_id,
|
|
||||||
client_secret,
|
|
||||||
redirect_uris[0]
|
|
||||||
);
|
|
||||||
|
|
||||||
const token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
|
||||||
oAuth2Client.setCredentials(token);
|
|
||||||
|
|
||||||
oAuth2Client.on("tokens", (newTokens) => {
|
|
||||||
const current = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
|
||||||
fs.writeFileSync(
|
|
||||||
tokenPath,
|
|
||||||
JSON.stringify({ ...current, ...newTokens }, null, 2)
|
|
||||||
);
|
|
||||||
console.error(`Token refreshed and saved for account "${account}".`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return oAuth2Client;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGmailClient(account: string): gmail_v1.Gmail {
|
|
||||||
return google.gmail({ version: "v1", auth: getOAuth2Client(account) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSheetsClient(account: string): sheets_v4.Sheets {
|
|
||||||
return google.sheets({ version: "v4", auth: getOAuth2Client(account) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCalendarClient(account: string): calendar_v3.Calendar {
|
|
||||||
return google.calendar({ version: "v3", auth: getOAuth2Client(account) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSpreadsheetId(account: string): string {
|
|
||||||
const acct = accounts[account];
|
|
||||||
if (!acct?.spreadsheetId || acct.spreadsheetId === "PASTE_YOUR_SHEET_ID_HERE") {
|
|
||||||
throw new Error(
|
|
||||||
`No spreadsheetId configured for account "${account}" in accounts.json.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return acct.spreadsheetId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCalendarId(account: string): string {
|
|
||||||
return accounts[account]?.calendarId ?? "primary";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Email parsing helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function getHeader(
|
|
||||||
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
|
|
||||||
name: string
|
|
||||||
): string {
|
|
||||||
if (!headers) return "";
|
|
||||||
const header = headers.find(
|
|
||||||
(h) => h.name?.toLowerCase() === name.toLowerCase()
|
|
||||||
);
|
|
||||||
return header?.value ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeBody(message: gmail_v1.Schema$Message): string {
|
|
||||||
const parts = message.payload?.parts;
|
|
||||||
let encoded = "";
|
|
||||||
|
|
||||||
if (parts) {
|
|
||||||
// Multipart message — prefer text/plain
|
|
||||||
const textPart = parts.find((p) => p.mimeType === "text/plain");
|
|
||||||
encoded = textPart?.body?.data ?? "";
|
|
||||||
|
|
||||||
// Fallback to text/html if no plain text
|
|
||||||
if (!encoded) {
|
|
||||||
const htmlPart = parts.find((p) => p.mimeType === "text/html");
|
|
||||||
encoded = htmlPart?.body?.data ?? "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Single-part message
|
|
||||||
encoded = message.payload?.body?.data ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!encoded) {
|
|
||||||
return message.snippet ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return Buffer.from(encoded, "base64url").toString("utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// MCP Server
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const server = new McpServer({
|
|
||||||
name: "assistant",
|
|
||||||
version: "1.0.0",
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Prompt: review_emails (work account)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
server.registerPrompt(
|
|
||||||
"review_emails",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
`Review WORK inbox (${accounts.work?.label ?? "work"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`,
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
const phase1 = loadClassificationPrompt();
|
|
||||||
const phase2 = loadActionPrompt();
|
|
||||||
return {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user" as const,
|
|
||||||
content: {
|
|
||||||
type: "text" as const,
|
|
||||||
text:
|
|
||||||
`ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
|
|
||||||
(phase1 || "Review my new emails and classify them by job application category.") +
|
|
||||||
(phase2 ? `\n\n${phase2}` : ""),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Prompt: review_secondary_emails (secondary account)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
server.registerPrompt(
|
|
||||||
"review_secondary_emails",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
`Review SECONDARY inbox (${accounts.secondary?.label ?? "secondary"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`,
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
const phase1 = loadClassificationPrompt();
|
|
||||||
const phase2 = loadActionPrompt();
|
|
||||||
return {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user" as const,
|
|
||||||
content: {
|
|
||||||
type: "text" as const,
|
|
||||||
text:
|
|
||||||
`ACCOUNT: Use account = "secondary" for ALL tool calls in this session.\n\n` +
|
|
||||||
(phase1 || "Review my new emails and classify them by job application category.") +
|
|
||||||
(phase2 ? `\n\n${phase2}` : ""),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool: fetch_new_emails
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
server.registerTool(
|
|
||||||
"fetch_new_emails",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
|
|
||||||
"subject, message ID, and body text for each message. The message IDs " +
|
|
||||||
"can be passed to delete_emails later. Specify which account to fetch from.",
|
|
||||||
inputSchema: {
|
|
||||||
account: accountSchema,
|
|
||||||
maxResults: z
|
|
||||||
.number()
|
|
||||||
.min(1)
|
|
||||||
.max(100)
|
|
||||||
.describe("Maximum number of unread emails to fetch (1-100)"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ account, maxResults }) => {
|
|
||||||
try {
|
|
||||||
const gmail = getGmailClient(account);
|
|
||||||
|
|
||||||
const listResponse = await gmail.users.messages.list({
|
|
||||||
userId: "me",
|
|
||||||
q: "is:unread",
|
|
||||||
maxResults,
|
|
||||||
});
|
|
||||||
|
|
||||||
const messageIds = listResponse.data.messages ?? [];
|
|
||||||
|
|
||||||
if (messageIds.length === 0) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: "No unread emails found." }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const emails: string[] = [];
|
|
||||||
|
|
||||||
for (const msg of messageIds) {
|
|
||||||
const detail = await gmail.users.messages.get({
|
|
||||||
userId: "me",
|
|
||||||
id: msg.id!,
|
|
||||||
format: "full",
|
|
||||||
});
|
|
||||||
|
|
||||||
const headers = detail.data.payload?.headers;
|
|
||||||
const from = getHeader(headers, "From");
|
|
||||||
const subject = getHeader(headers, "Subject");
|
|
||||||
const date = getHeader(headers, "Date");
|
|
||||||
const body = decodeBody(detail.data);
|
|
||||||
|
|
||||||
// Truncate body to avoid overwhelming the context window
|
|
||||||
const truncatedBody =
|
|
||||||
body.length > 2000 ? body.substring(0, 2000) + "\n[...truncated]" : body;
|
|
||||||
|
|
||||||
emails.push(
|
|
||||||
[
|
|
||||||
`MESSAGE_ID: ${msg.id}`,
|
|
||||||
`FROM: ${from}`,
|
|
||||||
`DATE: ${date}`,
|
|
||||||
`SUBJECT: ${subject}`,
|
|
||||||
`BODY:\n${truncatedBody}`,
|
|
||||||
].join("\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const classificationInstructions = loadClassificationPrompt();
|
|
||||||
const actionInstructions = loadActionPrompt();
|
|
||||||
const instructionsBlock =
|
|
||||||
(classificationInstructions
|
|
||||||
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS (PHASE 1):\n${"=".repeat(60)}\n${classificationInstructions}`
|
|
||||||
: "") +
|
|
||||||
(actionInstructions
|
|
||||||
? `\n\n${"=".repeat(60)}\nACTION INSTRUCTIONS (PHASE 2):\n${"=".repeat(60)}\n${actionInstructions}`
|
|
||||||
: "");
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text:
|
|
||||||
`Found ${emails.length} unread email(s):\n\n` +
|
|
||||||
`${"=".repeat(60)}\n${emails.join(`\n${"=".repeat(60)}\n`)}` +
|
|
||||||
instructionsBlock,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: `Error fetching emails: ${errMsg}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool: delete_emails
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
server.registerTool(
|
|
||||||
"delete_emails",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Move emails to trash by their Gmail message IDs. Use this for " +
|
|
||||||
"category A (acknowledgements) and category C (rejections) emails. " +
|
|
||||||
"Specify which account the emails belong to.",
|
|
||||||
inputSchema: {
|
|
||||||
account: accountSchema,
|
|
||||||
messageIds: z
|
|
||||||
.array(z.string())
|
|
||||||
.describe("Array of Gmail message IDs to move to trash"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ account, messageIds }) => {
|
|
||||||
try {
|
|
||||||
const gmail = getGmailClient(account);
|
|
||||||
const results: string[] = [];
|
|
||||||
|
|
||||||
for (const id of messageIds) {
|
|
||||||
try {
|
|
||||||
await gmail.users.messages.trash({
|
|
||||||
userId: "me",
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
results.push(`Trashed: ${id}`);
|
|
||||||
} catch (err) {
|
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
results.push(`Failed to trash ${id}: ${errMsg}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: `Delete results:\n${results.join("\n")}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: `Error deleting emails: ${errMsg}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool: append_to_summary
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
interface SummaryEntry {
|
|
||||||
senderName: string;
|
|
||||||
senderEmail: string;
|
|
||||||
dateReceived: string;
|
|
||||||
subject: string;
|
|
||||||
category: "B" | "D";
|
|
||||||
addedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"append_to_summary",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Append classified email entries to the local summary file. Use this " +
|
|
||||||
"for category B (advancement to next step) and category D (other) emails. " +
|
|
||||||
"Each entry records the sender, date, subject, and category. " +
|
|
||||||
"Specify which account the emails belong to.",
|
|
||||||
inputSchema: {
|
|
||||||
account: accountSchema,
|
|
||||||
entries: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
senderName: z.string().describe("Name of the sender"),
|
|
||||||
senderEmail: z.string().describe("Email address of the sender"),
|
|
||||||
dateReceived: z
|
|
||||||
.string()
|
|
||||||
.describe("Date and time the email was received"),
|
|
||||||
subject: z.string().describe("Email subject line"),
|
|
||||||
category: z
|
|
||||||
.enum(["B", "D"])
|
|
||||||
.describe(
|
|
||||||
"Category: B = advancement to next step, D = other/uncategorized"
|
|
||||||
),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.describe("Array of email summary entries to append"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ account, entries }) => {
|
|
||||||
try {
|
|
||||||
const summaryPath = getSummaryPath(account);
|
|
||||||
|
|
||||||
// Load existing summary or start fresh
|
|
||||||
let summary: SummaryEntry[] = [];
|
|
||||||
if (fs.existsSync(summaryPath)) {
|
|
||||||
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const nowIso = now.toISOString();
|
|
||||||
const newEntries: SummaryEntry[] = entries.map((e) => ({
|
|
||||||
...e,
|
|
||||||
addedAt: nowIso,
|
|
||||||
}));
|
|
||||||
|
|
||||||
summary.push(...newEntries);
|
|
||||||
|
|
||||||
// Purge entries older than 30 days
|
|
||||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
||||||
const cutoff = now.getTime() - RETENTION_MS;
|
|
||||||
const beforePurge = summary.length;
|
|
||||||
summary = summary.filter(
|
|
||||||
(entry) => new Date(entry.addedAt).getTime() >= cutoff
|
|
||||||
);
|
|
||||||
const purged = beforePurge - summary.length;
|
|
||||||
|
|
||||||
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
|
|
||||||
|
|
||||||
const purgeNote = purged > 0
|
|
||||||
? `\nPurged ${purged} entry/entries older than 30 days.`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text:
|
|
||||||
`Appended ${newEntries.length} entry/entries to summary.\n` +
|
|
||||||
`Total entries in summary: ${summary.length}\n` +
|
|
||||||
`Summary file: ${summaryPath}` +
|
|
||||||
purgeNote,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: `Error appending to summary: ${errMsg}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sheet column layout for "Recruiter Communication Log and Call Schedule"
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const SHEET_COLUMNS = [
|
|
||||||
"Recruiter Name", // A
|
|
||||||
"Recruiter Email", // B
|
|
||||||
"Recruiter Tel", // C
|
|
||||||
"Company/Role", // D
|
|
||||||
"First Contact", // E
|
|
||||||
"Subsequent Contact(s)", // F
|
|
||||||
"Recruiter Call Scheduled", // G
|
|
||||||
"Company Contact Info", // H
|
|
||||||
"Company First Interview", // I
|
|
||||||
"Company Second Interview", // J
|
|
||||||
];
|
|
||||||
|
|
||||||
const SHEET_RANGE = "Sheet1";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool: log_recruiter_contact
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
server.registerTool(
|
|
||||||
"log_recruiter_contact",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Log or update recruiter contact info in the tracking spreadsheet. " +
|
|
||||||
"If a row already exists for the same recruiterEmail + companyRole, it " +
|
|
||||||
"updates the existing row (merging non-empty fields). Otherwise appends " +
|
|
||||||
"a new row. Use for Category B emails after classification.",
|
|
||||||
inputSchema: {
|
|
||||||
account: accountSchema,
|
|
||||||
recruiterName: z.string().describe("Recruiter's full name"),
|
|
||||||
recruiterEmail: z.string().describe("Recruiter's email address"),
|
|
||||||
recruiterTel: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Recruiter's phone number, if mentioned in the email"),
|
|
||||||
companyRole: z
|
|
||||||
.string()
|
|
||||||
.describe("Company name and role the recruiter seeks to fill"),
|
|
||||||
firstContact: z
|
|
||||||
.string()
|
|
||||||
.describe("Date/time of first contact (from the email's Date header)"),
|
|
||||||
subsequentContacts: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Any follow-up contact context mentioned in the email"),
|
|
||||||
recruiterCallScheduled: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Scheduled call details: date, time, platform (Zoom/Teams), " +
|
|
||||||
"meeting link or phone, and whether they have your cell number"
|
|
||||||
),
|
|
||||||
companyContactInfo: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Company interviewer name/email/tel if advancing to company interview"),
|
|
||||||
companyFirstInterview: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Company first interview: date, time, platform, link/phone, cell number note"),
|
|
||||||
companySecondInterview: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Company second interview: date, time, platform, link/phone, cell number note"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({
|
|
||||||
account,
|
|
||||||
recruiterName,
|
|
||||||
recruiterEmail,
|
|
||||||
recruiterTel,
|
|
||||||
companyRole,
|
|
||||||
firstContact,
|
|
||||||
subsequentContacts,
|
|
||||||
recruiterCallScheduled,
|
|
||||||
companyContactInfo,
|
|
||||||
companyFirstInterview,
|
|
||||||
companySecondInterview,
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
const sheets = getSheetsClient(account);
|
|
||||||
const spreadsheetId = getSpreadsheetId(account);
|
|
||||||
|
|
||||||
const incomingRow = [
|
|
||||||
recruiterName,
|
|
||||||
recruiterEmail,
|
|
||||||
recruiterTel ?? "",
|
|
||||||
companyRole,
|
|
||||||
firstContact,
|
|
||||||
subsequentContacts ?? "",
|
|
||||||
recruiterCallScheduled ?? "",
|
|
||||||
companyContactInfo ?? "",
|
|
||||||
companyFirstInterview ?? "",
|
|
||||||
companySecondInterview ?? "",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Read existing data to check for a matching row
|
|
||||||
const existing = await sheets.spreadsheets.values.get({
|
|
||||||
spreadsheetId,
|
|
||||||
range: SHEET_RANGE,
|
|
||||||
});
|
|
||||||
const rows = existing.data.values ?? [];
|
|
||||||
|
|
||||||
// Find row matching recruiterEmail (col B) + companyRole (col D)
|
|
||||||
const emailNorm = recruiterEmail.toLowerCase().trim();
|
|
||||||
const roleNorm = companyRole.toLowerCase().trim();
|
|
||||||
let matchIdx = -1;
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
|
||||||
const rowEmail = (rows[i][1] ?? "").toString().toLowerCase().trim();
|
|
||||||
const rowRole = (rows[i][3] ?? "").toString().toLowerCase().trim();
|
|
||||||
if (rowEmail === emailNorm && rowRole === roleNorm) {
|
|
||||||
matchIdx = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchIdx >= 0) {
|
|
||||||
// Merge: keep existing values where incoming is empty
|
|
||||||
const existingRow = rows[matchIdx];
|
|
||||||
const merged = incomingRow.map((val, col) => {
|
|
||||||
if (col === 5 && val && existingRow[col]) {
|
|
||||||
// Subsequent Contacts: append rather than overwrite
|
|
||||||
return `${existingRow[col]}; ${val}`;
|
|
||||||
}
|
|
||||||
return val || existingRow[col] || "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const rowNum = matchIdx + 1; // 1-indexed
|
|
||||||
await sheets.spreadsheets.values.update({
|
|
||||||
spreadsheetId,
|
|
||||||
range: `${SHEET_RANGE}!A${rowNum}:J${rowNum}`,
|
|
||||||
valueInputOption: "USER_ENTERED",
|
|
||||||
requestBody: { values: [merged] },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: `Updated existing row ${rowNum} for ${recruiterName} / ${companyRole}.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// No match — append new row
|
|
||||||
await sheets.spreadsheets.values.append({
|
|
||||||
spreadsheetId,
|
|
||||||
range: SHEET_RANGE,
|
|
||||||
valueInputOption: "USER_ENTERED",
|
|
||||||
requestBody: { values: [incomingRow] },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: `Appended new row for ${recruiterName} / ${companyRole}.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: `Error logging recruiter contact: ${errMsg}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool: create_calendar_event
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
server.registerTool(
|
|
||||||
"create_calendar_event",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Create a Google Calendar event. Use immediately after logging a " +
|
|
||||||
"recruiter call, company first interview, or company second interview " +
|
|
||||||
"to the spreadsheet. One calendar event per scheduling column populated.",
|
|
||||||
inputSchema: {
|
|
||||||
account: accountSchema,
|
|
||||||
title: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Event title, e.g. '[Recruiter Call] Acme Corp - Sr Engineer' " +
|
|
||||||
"or '[Interview] Acme Corp - Sr Engineer'"
|
|
||||||
),
|
|
||||||
startDateTime: z
|
|
||||||
.string()
|
|
||||||
.describe("Event start in ISO 8601 format, e.g. 2026-02-20T14:00:00-06:00"),
|
|
||||||
durationMinutes: z
|
|
||||||
.number()
|
|
||||||
.min(5)
|
|
||||||
.max(480)
|
|
||||||
.optional()
|
|
||||||
.describe("Duration in minutes (default 60)"),
|
|
||||||
description: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Event description: recruiter/company contact info, notes, " +
|
|
||||||
"whether they have your cell number, etc."
|
|
||||||
),
|
|
||||||
location: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Video meeting link (Zoom/Teams URL) or phone number"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ account, title, startDateTime, durationMinutes, description, location }) => {
|
|
||||||
try {
|
|
||||||
const start = new Date(startDateTime);
|
|
||||||
|
|
||||||
if (start.getTime() < Date.now()) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text:
|
|
||||||
`Skipped calendar event "${title}" — the proposed date ` +
|
|
||||||
`(${start.toISOString()}) is in the past.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const calendar = getCalendarClient(account);
|
|
||||||
const calendarId = getCalendarId(account);
|
|
||||||
|
|
||||||
const end = new Date(start.getTime() + (durationMinutes ?? 60) * 60_000);
|
|
||||||
|
|
||||||
const event = await calendar.events.insert({
|
|
||||||
calendarId,
|
|
||||||
requestBody: {
|
|
||||||
summary: title,
|
|
||||||
start: {
|
|
||||||
dateTime: start.toISOString(),
|
|
||||||
},
|
|
||||||
end: {
|
|
||||||
dateTime: end.toISOString(),
|
|
||||||
},
|
|
||||||
description: description ?? "",
|
|
||||||
location: location ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text:
|
|
||||||
`Created calendar event: "${title}"\n` +
|
|
||||||
`Start: ${start.toISOString()}\n` +
|
|
||||||
`End: ${end.toISOString()}\n` +
|
|
||||||
`Event ID: ${event.data.id}\n` +
|
|
||||||
`Link: ${event.data.htmlLink}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: `Error creating calendar event: ${errMsg}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Start server
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
console.error("Assistant MCP Server running on stdio");
|
console.error("Assistant MCP Server running on stdio");
|
||||||
}
|
};
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error("Fatal error in main():", error);
|
console.error("Fatal error in main():", error);
|
||||||
|
|||||||
151
src/loaders/prompt-config-loaders.ts
Normal file
151
src/loaders/prompt-config-loaders.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { google, gmail_v1, sheets_v4, calendar_v3 } from "googleapis";
|
||||||
|
import { OAuth2Client } from "google-auth-library";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
export const PROJECT_ROOT = path.resolve(__dirname, "..", "..");
|
||||||
|
const SECRETS_DIR = path.join(PROJECT_ROOT, "accountsAndCredentials");
|
||||||
|
const CREDENTIALS_PATH = path.join(SECRETS_DIR, "credentials.json");
|
||||||
|
const ACCOUNTS_PATH = path.join(SECRETS_DIR, "accounts.json");
|
||||||
|
const PROMPTS_DIR = path.join(PROJECT_ROOT, "src", "prompts");
|
||||||
|
const CLASSIFY_PROMPT_PATH = path.join(PROMPTS_DIR, "classify-emails.txt");
|
||||||
|
const ACTION_PROMPT_PATH = path.join(PROMPTS_DIR, "take-action-on-emails.txt");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Account configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface AccountConfig {
|
||||||
|
label: string;
|
||||||
|
tokenFile: string;
|
||||||
|
spreadsheetId?: string;
|
||||||
|
calendarId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountsMap {
|
||||||
|
[key: string]: AccountConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadAccounts = (): AccountsMap => {
|
||||||
|
if (!fs.existsSync(ACCOUNTS_PATH)) {
|
||||||
|
throw new Error(`Missing accounts.json at ${ACCOUNTS_PATH}.`);
|
||||||
|
}
|
||||||
|
return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTokenPath = (account: string): string => {
|
||||||
|
const accts = loadAccounts();
|
||||||
|
const acct = accts[account];
|
||||||
|
if (!acct) {
|
||||||
|
const available = Object.keys(accts).join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`Unknown account "${account}". Available accounts: ${available}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return path.join(SECRETS_DIR, acct.tokenFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSummaryPath = (account: string): string => {
|
||||||
|
if (account === "work") {
|
||||||
|
return path.join(PROJECT_ROOT, "mailSummaries", "summary.json");
|
||||||
|
}
|
||||||
|
return path.join(PROJECT_ROOT, "mailSummaries", `summary-${account}.json`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VALID_ACCOUNTS = ["work", "secondary"] as const;
|
||||||
|
export const accounts = loadAccounts();
|
||||||
|
|
||||||
|
const accountDescription = VALID_ACCOUNTS
|
||||||
|
.map((key) => `"${key}" (${accounts[key]?.label ?? key})`)
|
||||||
|
.join(" or ");
|
||||||
|
|
||||||
|
export const accountSchema = z
|
||||||
|
.enum(VALID_ACCOUNTS)
|
||||||
|
.describe(`Which email account to use: ${accountDescription}`);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prompt loaders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const loadPromptFile = (filePath: string, label: string): string => {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error(`Warning: ${filePath} not found. ${label} will be missing.`);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return fs.readFileSync(filePath, "utf-8");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadClassificationPrompt = (): string =>
|
||||||
|
loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions");
|
||||||
|
|
||||||
|
export const loadActionPrompt = (): string =>
|
||||||
|
loadPromptFile(ACTION_PROMPT_PATH, "Action instructions");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth helpers — generic OAuth2 client, then service-specific factories
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const getOAuth2Client = (account: string): OAuth2Client => {
|
||||||
|
const tokenPath = getTokenPath(account);
|
||||||
|
|
||||||
|
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing credentials.json at ${CREDENTIALS_PATH}. Run "npm run auth" first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(tokenPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing token file at ${tokenPath} for account "${account}". Run "npm run auth" first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
|
||||||
|
const { client_id, client_secret, redirect_uris } =
|
||||||
|
credentials.installed || credentials.web;
|
||||||
|
|
||||||
|
const oAuth2Client = new google.auth.OAuth2(
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
redirect_uris[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
||||||
|
oAuth2Client.setCredentials(token);
|
||||||
|
|
||||||
|
oAuth2Client.on("tokens", (newTokens) => {
|
||||||
|
const current = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
||||||
|
fs.writeFileSync(
|
||||||
|
tokenPath,
|
||||||
|
JSON.stringify({ ...current, ...newTokens }, null, 2)
|
||||||
|
);
|
||||||
|
console.error(`Token refreshed and saved for account "${account}".`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return oAuth2Client;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGmailClient = (account: string): gmail_v1.Gmail =>
|
||||||
|
google.gmail({ version: "v1", auth: getOAuth2Client(account) });
|
||||||
|
|
||||||
|
export const getSheetsClient = (account: string): sheets_v4.Sheets =>
|
||||||
|
google.sheets({ version: "v4", auth: getOAuth2Client(account) });
|
||||||
|
|
||||||
|
export const getCalendarClient = (account: string): calendar_v3.Calendar =>
|
||||||
|
google.calendar({ version: "v3", auth: getOAuth2Client(account) });
|
||||||
|
|
||||||
|
export const getSpreadsheetId = (account: string): string => {
|
||||||
|
const acct = accounts[account];
|
||||||
|
if (!acct?.spreadsheetId || acct.spreadsheetId === "PASTE_YOUR_SHEET_ID_HERE") {
|
||||||
|
throw new Error(
|
||||||
|
`No spreadsheetId configured for account "${account}" in accounts.json.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return acct.spreadsheetId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCalendarId = (account: string): string =>
|
||||||
|
accounts[account]?.calendarId ?? "primary";
|
||||||
56
src/prompt-controller-service/prompt-controller-service.ts
Normal file
56
src/prompt-controller-service/prompt-controller-service.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { server } from "../McpServer.js";
|
||||||
|
import {
|
||||||
|
accounts,
|
||||||
|
loadClassificationPrompt,
|
||||||
|
loadActionPrompt,
|
||||||
|
} from "../loaders/prompt-config-loaders.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prompt: Classify Emails (Phase 1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
server.registerPrompt(
|
||||||
|
"classify-emails",
|
||||||
|
{
|
||||||
|
description: "Phase 1: Organize, prioritize, and summarize incoming recruiter emails.",
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const phase1 = loadClassificationPrompt();
|
||||||
|
return {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user" as const,
|
||||||
|
content: {
|
||||||
|
type: "text" as const,
|
||||||
|
text: `ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
|
||||||
|
(phase1 || "Review my new emails and classify them by job application category.")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prompt: Take Action on Emails (Phase 2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
server.registerPrompt(
|
||||||
|
"take-action-on-emails",
|
||||||
|
{
|
||||||
|
description: "Phase 2: Log Category B recruiters to spreadsheet and create calendar events.",
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const phase2 = loadActionPrompt();
|
||||||
|
return {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user" as const,
|
||||||
|
content: {
|
||||||
|
type: "text" as const,
|
||||||
|
text: `ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
|
||||||
|
(phase2 || "Process remaining action items for flagged recruiters.")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
107
src/prompts/classify-emails-general.txt
Normal file
107
src/prompts/classify-emails-general.txt
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
You are an expert Executive Assistant AI specialized in email management. Your goal is to organize, prioritize, and triage incoming emails to maximize productivity and ensure nothing important is missed.
|
||||||
|
|
||||||
|
Review the inbox emails provided. Apply the following instructions.
|
||||||
|
|
||||||
|
STEP 1: CONSTRAINTS
|
||||||
|
- NEVER fabricate information. If data is not explicitly present in an email, it does not exist.
|
||||||
|
- When classification is ambiguous, err on the side of higher priority (e.g., classify as Action Required rather than Informational).
|
||||||
|
|
||||||
|
STEP 2: CLASSIFY each email into exactly one category.
|
||||||
|
|
||||||
|
Category A - Action Required
|
||||||
|
Emails that require a response, decision, or task completion from the user. Indicators include:
|
||||||
|
1. Direct questions addressed to the user
|
||||||
|
2. Requests for information, documents, or approvals
|
||||||
|
3. Meeting invitations or scheduling requests
|
||||||
|
4. Deadlines or time-sensitive requests
|
||||||
|
5. Follow-ups on prior conversations that need a reply
|
||||||
|
6. Emails from humans (not automation) explicitly asking for something
|
||||||
|
|
||||||
|
This category does NOT include:
|
||||||
|
- Automated notifications that require only a click (e.g., "verify your email," "reset password")
|
||||||
|
- Subscription confirmations or account alerts
|
||||||
|
|
||||||
|
Category B - Informational
|
||||||
|
Emails that provide useful information but require no immediate action. Examples:
|
||||||
|
1. Project updates or status reports
|
||||||
|
2. FYI messages or CC'd correspondence
|
||||||
|
3. Newsletters or digests the user has opted into
|
||||||
|
4. Industry news or curated content
|
||||||
|
5. Internal announcements (policy changes, team updates, etc.)
|
||||||
|
6. Shared documents or resources for reference
|
||||||
|
|
||||||
|
Category C - Transactional
|
||||||
|
Automated or system-generated emails confirming an action or status. Examples:
|
||||||
|
1. Order confirmations, shipping notifications, delivery updates
|
||||||
|
2. Payment receipts and invoices
|
||||||
|
3. Appointment reminders from services (doctors, salons, etc.)
|
||||||
|
4. Travel itineraries and booking confirmations
|
||||||
|
5. Password resets, login alerts, two-factor codes
|
||||||
|
6. Subscription renewals or billing notices
|
||||||
|
|
||||||
|
Category D - Low Priority
|
||||||
|
Emails that can be safely ignored, archived, or deleted. Examples:
|
||||||
|
1. Marketing and promotional emails
|
||||||
|
2. Sales outreach from unknown senders
|
||||||
|
3. Surveys or feedback requests
|
||||||
|
4. Social media notifications
|
||||||
|
5. Spam or unsolicited bulk mail
|
||||||
|
6. Emails from mailing lists the user rarely engages with
|
||||||
|
|
||||||
|
Category E - Personal
|
||||||
|
Emails from known personal contacts or containing personal (non-work) content. Examples:
|
||||||
|
1. Messages from friends or family
|
||||||
|
2. Personal appointment confirmations
|
||||||
|
3. Social invitations or event coordination
|
||||||
|
4. Non-work correspondence from colleagues
|
||||||
|
|
||||||
|
STEP 3: MARK PROCESSED EMAILS
|
||||||
|
|
||||||
|
Star all emails classified as Category A (Action Required).
|
||||||
|
Do NOT star emails in Categories B, C, D, or E.
|
||||||
|
|
||||||
|
STEP 4: REPORT EMAIL COUNT
|
||||||
|
|
||||||
|
Confirm the total number of emails reviewed in the chat.
|
||||||
|
|
||||||
|
STEP 5: SUMMARIZE PRIORITY EMAILS
|
||||||
|
|
||||||
|
Summarize all Category A (Action Required) and Category E (Personal) emails in the chat, under separate headers.
|
||||||
|
|
||||||
|
For each email, include:
|
||||||
|
- From: sender name and email address
|
||||||
|
- Subject: email subject line
|
||||||
|
- Date: date received
|
||||||
|
- Time: time received (convert to user's local timezone if known, otherwise use original)
|
||||||
|
- Summary: 1-2 sentence summary of the content and what action may be needed
|
||||||
|
|
||||||
|
STEP 6: EXECUTE ACTIONS
|
||||||
|
|
||||||
|
For Category A emails:
|
||||||
|
- If the email contains a meeting request with a specific date/time, create a calendar event using the create_calendar_event tool.
|
||||||
|
- Title: Use the subject line or a clear description of the meeting
|
||||||
|
- Start: Extract date/time from the email (ISO 8601 format)
|
||||||
|
- Duration: Use the duration specified, or default to 60 minutes
|
||||||
|
- Location: Include any meeting link, address, or phone number mentioned
|
||||||
|
- Description: Include relevant context (attendees, agenda, contact info)
|
||||||
|
|
||||||
|
For Category C emails:
|
||||||
|
- If the email contains an appointment or reservation with a specific date/time (e.g., doctor appointment, flight, hotel check-in), create a calendar event.
|
||||||
|
- Title: "[Reminder] " + service/company name + description
|
||||||
|
- Start: Extract date/time from the email
|
||||||
|
- Duration: Use duration if specified, or default to 60 minutes
|
||||||
|
- Location: Include address or relevant details
|
||||||
|
- Description: Include confirmation numbers, contact info, or instructions
|
||||||
|
|
||||||
|
For Category D emails:
|
||||||
|
- Use delete_emails to move these to trash (if configured for auto-cleanup).
|
||||||
|
|
||||||
|
STRICT CONSTRAINTS FOR CALENDAR EVENTS:
|
||||||
|
- Do not fabricate dates, times, links, phone numbers, or any other information.
|
||||||
|
- Before creating an event, verify the proposed date is in the future. If the date has passed, skip calendar creation.
|
||||||
|
- Do not create duplicate events for the same appointment (same date/time and same organizer/service).
|
||||||
|
- Use the same account for calendar events as was used for email fetching.
|
||||||
|
|
||||||
|
STEP 7: LOG SUMMARY
|
||||||
|
|
||||||
|
Use append_to_summary to log all Category A and Category E emails for future reference.
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
You are an expert Executive Assistant AI specialized in email management and productivity. Your goal is to organize, prioritize, and summarize incoming emails to maximize user efficiency.
|
You are an expert Executive Assistant AI specialized in email management. Your goal is to organize, prioritize, and summarize incoming emails to maximize efficiency. I have been applying for jobs, which generates a large volume of responses.
|
||||||
|
|
||||||
I have been applying for jobs, which generates a large volume of responses. Review my inbox and apply the following instructions to all new emails.
|
Review the first 200 inbox emails. Apply the following instructions.
|
||||||
|
|
||||||
STEP 1: Constraints
|
STEP 1: Constraint
|
||||||
- Do not make up information.
|
- NEVER make up information.
|
||||||
|
|
||||||
STEP 2: CLASSIFY each email into exactly one category.
|
STEP 2: CLASSIFY each email into exactly one category.
|
||||||
|
|
||||||
Category A - Acknowledgement Only
|
Category A - Acknowledgement Only
|
||||||
The employer confirms receipt of my application but requires no action from me. Typical language: "We received your application," "Your application is under review," "We will contact you if selected." No next steps are requested.
|
The employer confirms receipt of my application but requires no action from me. Typical language: "We received your application," "Your application is under review," "We will contact you if selected."
|
||||||
|
|
||||||
Category B - Advancement to Next Step
|
Category B - Advancement to a Next Step
|
||||||
The employer wants to move forward. This includes: invitations to schedule a phone screen or interview, requests to complete an assessment or assignment, requests for additional information or documents, or any communication that requires a response or action from me. If an email both acknowledges receipt AND requests action, classify it as B.
|
The company wants to move forward. This includes, for example:
|
||||||
|
|
||||||
|
1. Invitations to schedule a phone screen or interview
|
||||||
|
2. Requests to complete an assessment or assignment
|
||||||
|
3. Requests for additional information or documents, from a human. This item is a bit more fine grained than others, because it does not include, i.e. automated requests to provide EEOC demographic information, verify identity, create an account password, etc. An important factor here is automated messaging versus a "human in the loop" on the company side seeking to connect. Use your judgment. Do not stop the process to ask questions about any emails. Rather, when in doubt, err on the side of inclusion and move on.
|
||||||
|
4. Emails seeking to schedule a "second round", "third round" interview, further discussion or screening.
|
||||||
|
5. Emails that state that I missed a prior email, appointment or request *and* come from a human, not automation. Again, when in doubt err on the side of inclusion.
|
||||||
|
6. If an email both acknowledges receipt AND requests action, classify it as B.
|
||||||
|
|
||||||
|
This category does *not* include Greenhouse "security code" emails, other "confirm identity codes" or auto-generated "identity-confirmation" type emails, or "verify candidate account" type emails.
|
||||||
|
|
||||||
Category C - Rejection
|
Category C - Rejection
|
||||||
The employer declines to move forward. Typical language: "We have decided to pursue other candidates," "Unfortunately, you were not selected," "We will not be moving forward with your application."
|
The employer declines to move forward. Typical language: "We have decided to pursue other candidates," "Unfortunately, you were not selected," "We will not be moving forward with your application."
|
||||||
@@ -19,13 +28,53 @@ The employer declines to move forward. Typical language: "We have decided to pur
|
|||||||
Category D - Other
|
Category D - Other
|
||||||
The email does not fit into categories A, B, or C. This includes non-job-application emails, newsletters, promotional content, or ambiguous messages that do not clearly match another category.
|
The email does not fit into categories A, B, or C. This includes non-job-application emails, newsletters, promotional content, or ambiguous messages that do not clearly match another category.
|
||||||
|
|
||||||
STEP 3: DELETE emails classified as Category A and Category C.
|
STEP 3: STAR REVIEWED EMAILS
|
||||||
|
|
||||||
STEP 4: ("Anti step") - NEVER DELETE Category B emails.
|
For every Category A, C, and D email, apply the Gmail "STARRED" system label or use your email tool to add a star to the message.
|
||||||
|
DO NOT add a star to any Category B email.
|
||||||
|
|
||||||
STEP 5: SUMMARIZE emails classified as Category B and Category D. For each, include:
|
STEP 4: Confirm, via the chat window, the total number of emails fetched.
|
||||||
|
|
||||||
|
STEP 5: SUMMARIZE emails classified as Category B and Category D, under separate headers, in the chat.
|
||||||
|
For each, state:
|
||||||
1. Sender name and email address
|
1. Sender name and email address
|
||||||
2. Email subject line
|
2. Email subject line
|
||||||
3. Date and time received
|
3. Date and time received
|
||||||
4. Category (B or D)
|
4. Category (B or D)
|
||||||
5. Suggested action I should take
|
5. Summary of body
|
||||||
|
|
||||||
|
STEP 6: Process check **STRICT COMPLIANCE REQUIRED** **DO NOT FORGET***:
|
||||||
|
|
||||||
|
Summarize all "Category B" and "Category D" emails in the chat.
|
||||||
|
|
||||||
|
**STRICT COMPLIANCE REQUIRED** **DO NOT FORGET***
|
||||||
|
Inlude headers:
|
||||||
|
From (sender)
|
||||||
|
Subject (one line)
|
||||||
|
Date
|
||||||
|
Time (IN EASTERN STANDARD TIME)
|
||||||
|
Body Summary
|
||||||
|
|
||||||
|
Star all emails reviewed and classified as Category A, C or D.
|
||||||
|
Do not star emails reviewed and classified as Category B.
|
||||||
|
Do not star unreviewed emails.
|
||||||
|
|
||||||
|
STEP 7: CREATE CALENDAR EVENTS
|
||||||
|
Perform the following actions for each "Category B" email:
|
||||||
|
Immediately after logging to the spreadsheet, check whether ANY of these fields were populated or updated:
|
||||||
|
- Recruiter Call Scheduled
|
||||||
|
- Company First Interview
|
||||||
|
- Company Second Interview
|
||||||
|
|
||||||
|
For EACH of the above fields that contains a date/time, create a Google Calendar event using the create_calendar_event tool:
|
||||||
|
- Title format: "[<Recruiter Name> Call] Company - Role" or "[First Interview] Company - Role" or "[Second Interview] Company - Role"
|
||||||
|
- Start date/time: extracted from the email (convert to ISO 8601)
|
||||||
|
- Duration: 30 minutes unless otherwise specified in the email
|
||||||
|
- Location: the video meeting link, phone number, or platform name
|
||||||
|
- Description: include recruiter or company contact name/email/phone, and note whether they have your cell number
|
||||||
|
|
||||||
|
STRICT CONSTRAINTS:
|
||||||
|
- Do not make up phone numbers, links, dates, or anything at all. If information does not exist in text within a verifiable email message, IT DOES NOT EXIST.
|
||||||
|
- Before creating a calendar event: 1) verify today's date from an external, reliable source; 2) compare the proposed meeting date/time against today's date; 3) if the meeting date has already passed, do NOT create a calendar event for it.
|
||||||
|
- Always use the same account for calendar events as was used for email fetching.
|
||||||
|
- Do not create duplicate calendar events for the same meeting (same company representative && same job description | same company && same date time)
|
||||||
@@ -1,45 +1,23 @@
|
|||||||
PHASE 2: TAKE ACTION ON CATEGORY B EMAILS
|
PHASE 2: TAKE ACTION ON CATEGORY B EMAILS
|
||||||
|
|
||||||
After completing classification (Phase 1), perform the following actions for each email classified as Category B (Advancement to Next Step).
|
After completing classification (Phase 1), perform the following actions for each email classified as Category B (Advancement to Next Step).
|
||||||
|
|
||||||
STEP 1: LOG TO SPREADSHEET
|
STEP 1: CREATE CALENDAR EVENTS
|
||||||
For each Category B email, extract as much of the following information as possible from the email contents and log it using the log_recruiter_contact tool:
|
|
||||||
|
|
||||||
A column exists for:
|
|
||||||
|
|
||||||
- Recruiter Name
|
|
||||||
- Recruiter Email
|
|
||||||
- Recruiter Phone/Tel
|
|
||||||
- Company/Role the recruiter seeks to fill
|
|
||||||
- First Contact: the date and time the email was received
|
|
||||||
- Subsequent Contacts: any follow-up communication with this recruiter about this role
|
|
||||||
- Recruiter Call Scheduled: if the email contains scheduling information, extract ALL details — date, time, platform (Zoom, MS Teams, Google Meet, etc.), meeting link, phone number to call — and note whether they confirm having your cell number or might not have it
|
|
||||||
|
|
||||||
A row should already exists for the same recruiter email + company/role combination (from a previous email), the tool will UPDATE the existing row. Provide all available fields and the tool handles merging.
|
|
||||||
|
|
||||||
STEP 2: CREATE CALENDAR EVENTS
|
|
||||||
Immediately after logging to the spreadsheet, check whether ANY of these fields were populated or updated:
|
Immediately after logging to the spreadsheet, check whether ANY of these fields were populated or updated:
|
||||||
- Recruiter Call Scheduled
|
- Recruiter Call Scheduled
|
||||||
- Company First Interview
|
- Company First Interview
|
||||||
- Company Second Interview
|
- Company Second Interview
|
||||||
|
|
||||||
For EACH of the above fields that contains a date/time, create a Google Calendar event using the create_calendar_event tool:
|
For EACH of the above fields that contains a date/time, create a Google Calendar event using the create_calendar_event tool:
|
||||||
- Title format: "[<Recruiter NaME> Call] Company - Role" or "[First Interview] Company - Role" or "[Second Interview] Company - Role"
|
- Title format: "[<Recruiter Name> Call] Company - Role" or "[First Interview] Company - Role" or "[Second Interview] Company - Role"
|
||||||
- Start date/time: extracted from the email (convert to ISO 8601)
|
- Start date/time: extracted from the email (convert to ISO 8601)
|
||||||
- Duration: 30 minutes unless otherwise specified in the email
|
- Duration: 30 minutes unless otherwise specified in the email
|
||||||
- Location: the video meeting link, phone number, or platform name
|
- Location: the video meeting link, phone number, or platform name
|
||||||
- Description: include recruiter or company contact name/email/phone, and note whether they have your cell number
|
- Description: include recruiter or company contact name/email/phone, and note whether they have your cell number
|
||||||
|
|
||||||
STEP 3: COMPANY INTERVIEW TRACKING
|
|
||||||
When an email indicates advancement beyond the recruiter screen to a company interview:
|
|
||||||
- Populate the Company First Interview or Company Second Interview field with details
|
|
||||||
- Those details should include company contact info (interviewer name, email, phone)
|
|
||||||
- Then immeidately create a calendar event for the new interview date/time including interviewer name, email, phone
|
|
||||||
|
|
||||||
STRICT CONSTRAINTS:
|
STRICT CONSTRAINTS:
|
||||||
- Do not make up phone numbers, links, dates, or anything at all. If it does not exist in text wihtin a verifiable email, IT DOES NOT EXIST.
|
- Do not make up phone numbers, links, dates, or anything at all. If it does not exist in text within a verifiable email, IT DOES NOT EXIST.
|
||||||
- PAST-DATE CHECK: Before creating a calendar event, compare the proposed meeting date/time against today's date. If the meeting date has already passed, do NOT create a calendar event for it. Still log it to the spreadsheet, but skip the calendar step. The tool will also enforce this server-side.
|
- PAST-DATE CHECK: Before creating a calendar event: 1) verify today's date from an external, reliable source; 2) compare the proposed meeting date/time against today's date; 3) if the meeting date has already passed, do NOT create a calendar event for it. Still log it to the spreadsheet, but skip the calendar step. The tool will also enforce this server-side.
|
||||||
- As described in more detail below, each new date/time value in a scheduling column = one new calendar event. Never skip this.
|
- As described in more detail below, each new date/time value in a scheduling column = one new calendar event. Never skip this.
|
||||||
- Always use the same account for calendar events as was used for email fetching.
|
- Always use the same account for calendar events as was used for email fetching.
|
||||||
- Do not create duplicate calendar events for the same meeting.
|
- Do not create duplicate calendar events for the same meeting (same company representative && same job description | same company && same date time)
|
||||||
- If information is not present in the email, leave that field empty do ot guess.
|
- If information is not present in the email, leave that field empty (it's OK!) - do not guess or make up information.
|
||||||
|
|||||||
111
src/tools/tools-calendar.ts
Normal file
111
src/tools/tools-calendar.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { server } from "../McpServer.js";
|
||||||
|
import {
|
||||||
|
accountSchema,
|
||||||
|
getCalendarClient,
|
||||||
|
getCalendarId,
|
||||||
|
} from "../loaders/prompt-config-loaders.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tool: create_calendar_event
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
server.registerTool(
|
||||||
|
"create_calendar_event",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Create a Google Calendar event. Use immediately after logging a " +
|
||||||
|
"recruiter call, company first interview, or company second interview " +
|
||||||
|
"to the spreadsheet. One calendar event per scheduling column populated.",
|
||||||
|
inputSchema: {
|
||||||
|
account: accountSchema,
|
||||||
|
title: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Event title, e.g. '[Recruiter Call] Acme Corp - Sr Engineer' " +
|
||||||
|
"or '[Interview] Acme Corp - Sr Engineer'"
|
||||||
|
),
|
||||||
|
startDateTime: z
|
||||||
|
.string()
|
||||||
|
.describe("Event start in ISO 8601 format, e.g. 2026-02-20T14:00:00-06:00"),
|
||||||
|
durationMinutes: z
|
||||||
|
.number()
|
||||||
|
.min(5)
|
||||||
|
.max(480)
|
||||||
|
.optional()
|
||||||
|
.describe("Duration in minutes (default 60)"),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Event description: recruiter/company contact info, notes, " +
|
||||||
|
"whether they have your cell number, etc."
|
||||||
|
),
|
||||||
|
location: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Video meeting link (Zoom/Teams URL) or phone number"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ account, title, startDateTime, durationMinutes, description, location }) => {
|
||||||
|
try {
|
||||||
|
const start = new Date(startDateTime);
|
||||||
|
|
||||||
|
if (start.getTime() < Date.now()) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text:
|
||||||
|
`Skipped calendar event "${title}" — the proposed date ` +
|
||||||
|
`(${start.toISOString()}) is in the past.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = getCalendarClient(account);
|
||||||
|
const calendarId = getCalendarId(account);
|
||||||
|
|
||||||
|
const end = new Date(start.getTime() + (durationMinutes ?? 60) * 60_000);
|
||||||
|
|
||||||
|
const event = await calendar.events.insert({
|
||||||
|
calendarId,
|
||||||
|
requestBody: {
|
||||||
|
summary: title,
|
||||||
|
start: {
|
||||||
|
dateTime: start.toISOString(),
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
dateTime: end.toISOString(),
|
||||||
|
},
|
||||||
|
description: description ?? "",
|
||||||
|
location: location ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text:
|
||||||
|
`Created calendar event: "${title}"\n` +
|
||||||
|
`Start: ${start.toISOString()}\n` +
|
||||||
|
`End: ${end.toISOString()}\n` +
|
||||||
|
`Event ID: ${event.data.id}\n` +
|
||||||
|
`Link: ${event.data.htmlLink}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error creating calendar event: ${errMsg}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
360
src/tools/tools-email.ts
Normal file
360
src/tools/tools-email.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { gmail_v1 } from "googleapis";
|
||||||
|
import fs from "fs";
|
||||||
|
import { server } from "../McpServer.js";
|
||||||
|
import {
|
||||||
|
accountSchema,
|
||||||
|
getGmailClient,
|
||||||
|
getSummaryPath,
|
||||||
|
loadClassificationPrompt,
|
||||||
|
loadActionPrompt,
|
||||||
|
} from "../loaders/prompt-config-loaders.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Email parsing helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const getHeader = (
|
||||||
|
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
|
||||||
|
name: string
|
||||||
|
): string => {
|
||||||
|
if (!headers) return "";
|
||||||
|
const header = headers.find(
|
||||||
|
(h) => h.name?.toLowerCase() === name.toLowerCase()
|
||||||
|
);
|
||||||
|
return header?.value ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeBody = (message: gmail_v1.Schema$Message): string => {
|
||||||
|
const parts = message.payload?.parts;
|
||||||
|
let encoded = "";
|
||||||
|
|
||||||
|
if (parts) {
|
||||||
|
const textPart = parts.find((p) => p.mimeType === "text/plain");
|
||||||
|
encoded = textPart?.body?.data ?? "";
|
||||||
|
|
||||||
|
if (!encoded) {
|
||||||
|
const htmlPart = parts.find((p) => p.mimeType === "text/html");
|
||||||
|
encoded = htmlPart?.body?.data ?? "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
encoded = message.payload?.body?.data ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!encoded) {
|
||||||
|
return message.snippet ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(encoded, "base64url").toString("utf-8");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tool: fetch_new_emails
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
server.registerTool(
|
||||||
|
"fetch_new_emails",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
|
||||||
|
"subject, message ID, and body text for each message. The message IDs " +
|
||||||
|
"must be passed to star_emails after evaluation. Specify which account to fetch from.",
|
||||||
|
inputSchema: {
|
||||||
|
account: accountSchema,
|
||||||
|
maxResults: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.describe("Maximum number of unread emails to fetch (1-200)"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ account, maxResults }) => {
|
||||||
|
try {
|
||||||
|
const gmail = getGmailClient(account);
|
||||||
|
|
||||||
|
const listResponse = await gmail.users.messages.list({
|
||||||
|
userId: "me",
|
||||||
|
q: "is:unread",
|
||||||
|
maxResults,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageIds = listResponse.data.messages ?? [];
|
||||||
|
|
||||||
|
if (messageIds.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: "No unread emails found." }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const emails: string[] = [];
|
||||||
|
|
||||||
|
for (const msg of messageIds) {
|
||||||
|
const detail = await gmail.users.messages.get({
|
||||||
|
userId: "me",
|
||||||
|
id: msg.id!,
|
||||||
|
format: "full",
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = detail.data.payload?.headers;
|
||||||
|
const from = getHeader(headers, "From");
|
||||||
|
const subject = getHeader(headers, "Subject");
|
||||||
|
const date = getHeader(headers, "Date");
|
||||||
|
const body = decodeBody(detail.data);
|
||||||
|
|
||||||
|
const truncatedBody =
|
||||||
|
body.length > 2000 ? body.substring(0, 2000) + "\n[...truncated]" : body;
|
||||||
|
|
||||||
|
emails.push(
|
||||||
|
[
|
||||||
|
`MESSAGE_ID: ${msg.id}`,
|
||||||
|
`FROM: ${from}`,
|
||||||
|
`DATE: ${date}`,
|
||||||
|
`SUBJECT: ${subject}`,
|
||||||
|
`BODY:\n${truncatedBody}`,
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classificationInstructions = loadClassificationPrompt();
|
||||||
|
const actionInstructions = loadActionPrompt();
|
||||||
|
const instructionsBlock =
|
||||||
|
(classificationInstructions
|
||||||
|
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS (PHASE 1):\n${"=".repeat(60)}\n${classificationInstructions}`
|
||||||
|
: "") +
|
||||||
|
(actionInstructions
|
||||||
|
? `\n\n${"=".repeat(60)}\nACTION INSTRUCTIONS (PHASE 2):\n${"=".repeat(60)}\n${actionInstructions}`
|
||||||
|
: "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text:
|
||||||
|
`Found ${emails.length} unread email(s):\n\n` +
|
||||||
|
`${"=".repeat(60)}\n${emails.join(`\n${"=".repeat(60)}\n`)}` +
|
||||||
|
instructionsBlock,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error fetching emails: ${errMsg}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tool: delete_emails
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
server.registerTool(
|
||||||
|
"delete_emails",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Move emails to trash by their Gmail message IDs. Use this for " +
|
||||||
|
"category A (acknowledgements) and category C (rejections) emails. " +
|
||||||
|
"Specify which account the emails belong to.",
|
||||||
|
inputSchema: {
|
||||||
|
account: accountSchema,
|
||||||
|
messageIds: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe("Array of Gmail message IDs to move to trash"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ account, messageIds }) => {
|
||||||
|
try {
|
||||||
|
const gmail = getGmailClient(account);
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
for (const id of messageIds) {
|
||||||
|
try {
|
||||||
|
await gmail.users.messages.trash({
|
||||||
|
userId: "me",
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
results.push(`Trashed: ${id}`);
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
results.push(`Failed to trash ${id}: ${errMsg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Delete results:\n${results.join("\n")}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error deleting emails: ${errMsg}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tool: append_to_summary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface SummaryEntry {
|
||||||
|
senderName: string;
|
||||||
|
senderEmail: string;
|
||||||
|
dateReceived: string;
|
||||||
|
subject: string;
|
||||||
|
category: "B" | "D";
|
||||||
|
addedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"append_to_summary",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Append classified email entries to the local summary file. Use this " +
|
||||||
|
"for category B (advancement to next step) and category D (other) emails. " +
|
||||||
|
"Each entry records the sender, date, subject, and category. " +
|
||||||
|
"Specify which account the emails belong to.",
|
||||||
|
inputSchema: {
|
||||||
|
account: accountSchema,
|
||||||
|
entries: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
senderName: z.string().describe("Name of the sender"),
|
||||||
|
senderEmail: z.string().describe("Email address of the sender"),
|
||||||
|
dateReceived: z
|
||||||
|
.string()
|
||||||
|
.describe("Date and time the email was received"),
|
||||||
|
subject: z.string().describe("Email subject line"),
|
||||||
|
category: z
|
||||||
|
.enum(["B", "D"])
|
||||||
|
.describe(
|
||||||
|
"Category: B = advancement to next step, D = other/uncategorized"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.describe("Array of email summary entries to append"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ account, entries }) => {
|
||||||
|
try {
|
||||||
|
const summaryPath = getSummaryPath(account);
|
||||||
|
|
||||||
|
let summary: SummaryEntry[] = [];
|
||||||
|
if (fs.existsSync(summaryPath)) {
|
||||||
|
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nowIso = now.toISOString();
|
||||||
|
const newEntries: SummaryEntry[] = entries.map((e) => ({
|
||||||
|
...e,
|
||||||
|
addedAt: nowIso,
|
||||||
|
}));
|
||||||
|
|
||||||
|
summary.push(...newEntries);
|
||||||
|
|
||||||
|
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const cutoff = now.getTime() - RETENTION_MS;
|
||||||
|
const beforePurge = summary.length;
|
||||||
|
summary = summary.filter(
|
||||||
|
(entry) => new Date(entry.addedAt).getTime() >= cutoff
|
||||||
|
);
|
||||||
|
const purged = beforePurge - summary.length;
|
||||||
|
|
||||||
|
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
|
||||||
|
|
||||||
|
const purgeNote = purged > 0
|
||||||
|
? `\nPurged ${purged} entry/entries older than 30 days.`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text:
|
||||||
|
`Appended ${newEntries.length} entry/entries to summary.\n` +
|
||||||
|
`Total entries in summary: ${summary.length}\n` +
|
||||||
|
`Summary file: ${summaryPath}` +
|
||||||
|
purgeNote,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error appending to summary: ${errMsg}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Tool: star_emails
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
server.registerTool(
|
||||||
|
"star_emails",
|
||||||
|
{
|
||||||
|
description: "Mark reviewed emails with a star and mark them as read in Gmail. " +
|
||||||
|
"Use this for all reviewed messages (Categories A, B, C, and D) " +
|
||||||
|
"to signal they have been processed. Specify which account the emails belong to.",
|
||||||
|
inputSchema: {
|
||||||
|
account: accountSchema,
|
||||||
|
messageIds: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe("Array of Gmail message IDs to star and mark read"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ account, messageIds }) => {
|
||||||
|
try {
|
||||||
|
if (messageIds.length === 0) {
|
||||||
|
return { content: [{ type: "text" as const, text: "No message IDs provided." }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const gmail = getGmailClient(account);
|
||||||
|
|
||||||
|
// Batch modify allows updating up to 1000 messages in a single API call
|
||||||
|
await gmail.users.messages.batchModify({
|
||||||
|
userId: "me",
|
||||||
|
requestBody: {
|
||||||
|
ids: messageIds,
|
||||||
|
addLabelIds: ["STARRED"],
|
||||||
|
removeLabelIds: ["UNREAD"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Successfully starred and marked read ${messageIds.length} email(s).`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error starring emails: ${errMsg}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
179
src/tools/tools-spreadsheet.ts
Normal file
179
src/tools/tools-spreadsheet.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { server } from "../McpServer.js";
|
||||||
|
import {
|
||||||
|
accountSchema,
|
||||||
|
getSheetsClient,
|
||||||
|
getSpreadsheetId,
|
||||||
|
} from "../loaders/prompt-config-loaders.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sheet column layout for "Recruiter Communication Log and Call Schedule"
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const SHEET_COLUMNS = [
|
||||||
|
"Recruiter Name", // A
|
||||||
|
"Recruiter Email", // B
|
||||||
|
"Recruiter Tel", // C
|
||||||
|
"Company/Role", // D
|
||||||
|
"First Contact", // E
|
||||||
|
"Subsequent Contact(s)", // F
|
||||||
|
"Recruiter Call Scheduled", // G
|
||||||
|
"Company Contact Info", // H
|
||||||
|
"Company First Interview", // I
|
||||||
|
"Company Second Interview", // J
|
||||||
|
];
|
||||||
|
|
||||||
|
const SHEET_RANGE = "Sheet1";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tool: log_recruiter_contact
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
server.registerTool(
|
||||||
|
"log_recruiter_contact",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Log or update recruiter contact info in the tracking spreadsheet. " +
|
||||||
|
"If a row already exists for the same recruiterEmail + companyRole, it " +
|
||||||
|
"updates the existing row (merging non-empty fields). Otherwise appends " +
|
||||||
|
"a new row. Use for Category B emails after classification.",
|
||||||
|
inputSchema: {
|
||||||
|
account: accountSchema,
|
||||||
|
recruiterName: z.string().describe("Recruiter's full name"),
|
||||||
|
recruiterEmail: z.string().describe("Recruiter's email address"),
|
||||||
|
recruiterTel: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Recruiter's phone number, if mentioned in the email"),
|
||||||
|
companyRole: z
|
||||||
|
.string()
|
||||||
|
.describe("Company name and role the recruiter seeks to fill"),
|
||||||
|
firstContact: z
|
||||||
|
.string()
|
||||||
|
.describe("Date/time of first contact (from the email's Date header)"),
|
||||||
|
subsequentContacts: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Any follow-up contact context mentioned in the email"),
|
||||||
|
recruiterCallScheduled: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Scheduled call details: date, time, platform (Zoom/Teams), " +
|
||||||
|
"meeting link or phone, and whether they have your cell number"
|
||||||
|
),
|
||||||
|
companyContactInfo: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Company interviewer name/email/tel if advancing to company interview"),
|
||||||
|
companyFirstInterview: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Company first interview: date, time, platform, link/phone, cell number note"),
|
||||||
|
companySecondInterview: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Company second interview: date, time, platform, link/phone, cell number note"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({
|
||||||
|
account,
|
||||||
|
recruiterName,
|
||||||
|
recruiterEmail,
|
||||||
|
recruiterTel,
|
||||||
|
companyRole,
|
||||||
|
firstContact,
|
||||||
|
subsequentContacts,
|
||||||
|
recruiterCallScheduled,
|
||||||
|
companyContactInfo,
|
||||||
|
companyFirstInterview,
|
||||||
|
companySecondInterview,
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const sheets = getSheetsClient(account);
|
||||||
|
const spreadsheetId = getSpreadsheetId(account);
|
||||||
|
|
||||||
|
const incomingRow = [
|
||||||
|
recruiterName,
|
||||||
|
recruiterEmail,
|
||||||
|
recruiterTel ?? "",
|
||||||
|
companyRole,
|
||||||
|
firstContact,
|
||||||
|
subsequentContacts ?? "",
|
||||||
|
recruiterCallScheduled ?? "",
|
||||||
|
companyContactInfo ?? "",
|
||||||
|
companyFirstInterview ?? "",
|
||||||
|
companySecondInterview ?? "",
|
||||||
|
];
|
||||||
|
|
||||||
|
const existing = await sheets.spreadsheets.values.get({
|
||||||
|
spreadsheetId,
|
||||||
|
range: SHEET_RANGE,
|
||||||
|
});
|
||||||
|
const rows = existing.data.values ?? [];
|
||||||
|
|
||||||
|
const emailNorm = recruiterEmail.toLowerCase().trim();
|
||||||
|
const roleNorm = companyRole.toLowerCase().trim();
|
||||||
|
let matchIdx = -1;
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const rowEmail = (rows[i][1] ?? "").toString().toLowerCase().trim();
|
||||||
|
const rowRole = (rows[i][3] ?? "").toString().toLowerCase().trim();
|
||||||
|
if (rowEmail === emailNorm && rowRole === roleNorm) {
|
||||||
|
matchIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchIdx >= 0) {
|
||||||
|
const existingRow = rows[matchIdx];
|
||||||
|
const merged = incomingRow.map((val, col) => {
|
||||||
|
if (col === 5 && val && existingRow[col]) {
|
||||||
|
return `${existingRow[col]}; ${val}`;
|
||||||
|
}
|
||||||
|
return val || existingRow[col] || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowNum = matchIdx + 1;
|
||||||
|
await sheets.spreadsheets.values.update({
|
||||||
|
spreadsheetId,
|
||||||
|
range: `${SHEET_RANGE}!A${rowNum}:J${rowNum}`,
|
||||||
|
valueInputOption: "USER_ENTERED",
|
||||||
|
requestBody: { values: [merged] },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Updated existing row ${rowNum} for ${recruiterName} / ${companyRole}.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await sheets.spreadsheets.values.append({
|
||||||
|
spreadsheetId,
|
||||||
|
range: SHEET_RANGE,
|
||||||
|
valueInputOption: "USER_ENTERED",
|
||||||
|
requestBody: { values: [incomingRow] },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Appended new row for ${recruiterName} / ${companyRole}.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error logging recruiter contact: ${errMsg}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"senderName": "Yelena Nikolov",
|
|
||||||
"senderEmail": "Yelena.Nikolov@meridianlink.com",
|
|
||||||
"dateReceived": "Tue, 17 Feb 2026 13:57:42 +0000",
|
|
||||||
"subject": "Interview next steps with MeridianLink",
|
|
||||||
"category": "B",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "LDEAskHR",
|
|
||||||
"senderEmail": "LDEAskHR@epiqglobal.com",
|
|
||||||
"dateReceived": "Fri, 13 Feb 2026 17:12:26 +0000",
|
|
||||||
"subject": "Immediate Action Requested - Background check",
|
|
||||||
"category": "B",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "Talent Experience Team",
|
|
||||||
"senderEmail": "talentsupport@mbopartners.com",
|
|
||||||
"dateReceived": "Fri, 13 Feb 2026 17:03:27 +0000",
|
|
||||||
"subject": "[URGENT ACTION NEEDED!] FINAL NOTICE - Security Training - OVERDUE",
|
|
||||||
"category": "B",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "Talent Experience Team",
|
|
||||||
"senderEmail": "talentsupport@mbopartners.com",
|
|
||||||
"dateReceived": "Fri, 13 Feb 2026 17:01:12 +0000",
|
|
||||||
"subject": "[ACTION NEEDED!] KPMG Ethics & Integrity Training for KPMG's Contingent Worker Training - OVERDUE",
|
|
||||||
"category": "B",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "Penny from Rocket Money",
|
|
||||||
"senderEmail": "support@rocketmoney.com",
|
|
||||||
"dateReceived": "Tue, 17 Feb 2026 14:00:20 +0000",
|
|
||||||
"subject": "Re: [External] New Cell Number -m Cannot 2FA to My Account",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "Mondaq Newsletters",
|
|
||||||
"senderEmail": "newsletters@webiis01.mondaq.com",
|
|
||||||
"dateReceived": "Tue, 17 Feb 2026 13:37:53 +0000",
|
|
||||||
"subject": "Mondaq Personalized News Alert",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "MoneyLion Partner",
|
|
||||||
"senderEmail": "partners@iemail.moneylion.com",
|
|
||||||
"dateReceived": "Tue, 17 Feb 2026 04:00:40 +0000",
|
|
||||||
"subject": "3 days left to view your personalized offers.",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "JobCopilot",
|
|
||||||
"senderEmail": "noreply@jobcopilot.com",
|
|
||||||
"dateReceived": "Mon, 16 Feb 2026 23:33:56 +0000",
|
|
||||||
"subject": "JobCopilot has applied to 50 jobs",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "no-reply@equifax.com",
|
|
||||||
"senderEmail": "no-reply@equifax.com",
|
|
||||||
"dateReceived": "Mon, 16 Feb 2026 21:01:48 +0000",
|
|
||||||
"subject": "New Equifax Credit Report Available",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "Call-On-Doc",
|
|
||||||
"senderEmail": "no-reply@callondoc.com",
|
|
||||||
"dateReceived": "Mon, 16 Feb 2026 20:46:24 +0000",
|
|
||||||
"subject": "CallonDoc - Your Login Email has Been Changed.",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "Bank of America",
|
|
||||||
"senderEmail": "customerservice@ealerts.bankofamerica.com",
|
|
||||||
"dateReceived": "Mon, 16 Feb 2026 09:55:20 -0600",
|
|
||||||
"subject": "Kenneth S Jannette sent you $150.00",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "Concora Credit",
|
|
||||||
"senderEmail": "donotreply_concoracredit@mail.concoracredit.com",
|
|
||||||
"dateReceived": "Sat, 14 Feb 2026 16:01:10 -0600",
|
|
||||||
"subject": "Your Statement Is Ready To View",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "MBO Talent Experience & Support Team",
|
|
||||||
"senderEmail": "talentsupport@mbopartners.com",
|
|
||||||
"dateReceived": "Fri, 13 Feb 2026 10:44:08 -0600",
|
|
||||||
"subject": "KPMG Standard Work Hours and Overtime Policy",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "Noun Project",
|
|
||||||
"senderEmail": "yourfriends@thenounproject.com",
|
|
||||||
"dateReceived": "Fri, 13 Feb 2026 01:00:53 +0000",
|
|
||||||
"subject": "Your Noun Project Invoice",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "The Termly Team",
|
|
||||||
"senderEmail": "termlyservices@email.termly.io",
|
|
||||||
"dateReceived": "Thu, 12 Feb 2026 20:16:59 +0000",
|
|
||||||
"subject": "Upcoming IAB TCF v2.3 update: disclosedVendors support",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"senderName": "Brave Search API",
|
|
||||||
"senderEmail": "search-api@brave.com",
|
|
||||||
"dateReceived": "Thu, 12 Feb 2026 19:57:43 +0000",
|
|
||||||
"subject": "New ToS & plans for the Brave Search API",
|
|
||||||
"category": "D",
|
|
||||||
"addedAt": "2026-02-17T14:10:30.911Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
167
test/fixtures/mock-emails.ts
vendored
Normal file
167
test/fixtures/mock-emails.ts
vendored
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Canned Gmail API responses representing one email from each category.
|
||||||
|
* Body text is base64url-encoded to match the real Gmail API format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const toBase64Url = (text: string): string =>
|
||||||
|
Buffer.from(text, "utf-8").toString("base64url");
|
||||||
|
|
||||||
|
// Category A — Acknowledgement only
|
||||||
|
const catABody = [
|
||||||
|
"Dear Steven,",
|
||||||
|
"",
|
||||||
|
"Thank you for your interest in the Senior Software Engineer position at Acme Corp.",
|
||||||
|
"We have received your application and it is currently under review.",
|
||||||
|
"We will contact you if your qualifications match our needs.",
|
||||||
|
"",
|
||||||
|
"Best regards,",
|
||||||
|
"Acme Talent Acquisition",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// Category B — Advancement to next step
|
||||||
|
const catBBody = [
|
||||||
|
"Hi Steven,",
|
||||||
|
"",
|
||||||
|
"Thanks for applying to the Full Stack Engineer role at Globex Corp.",
|
||||||
|
"We'd love to schedule a phone screen to discuss your background.",
|
||||||
|
"Are you available this Thursday at 2:00 PM CST?",
|
||||||
|
"Please join via Zoom: https://zoom.us/j/123456789",
|
||||||
|
"",
|
||||||
|
"Looking forward to connecting,",
|
||||||
|
"Jane Smith",
|
||||||
|
"Senior Recruiter, Globex Corp",
|
||||||
|
"jane.smith@globexcorp.com",
|
||||||
|
"(555) 867-5309",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// Category C — Rejection
|
||||||
|
const catCBody = [
|
||||||
|
"Dear Steven,",
|
||||||
|
"",
|
||||||
|
"Thank you for taking the time to interview for the Platform Engineer position at Initech.",
|
||||||
|
"After careful consideration, we have decided to pursue other candidates",
|
||||||
|
"whose experience more closely aligns with our current needs.",
|
||||||
|
"",
|
||||||
|
"We wish you the best in your job search.",
|
||||||
|
"",
|
||||||
|
"Regards,",
|
||||||
|
"Initech Recruiting Team",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// Category D — Other / non-job
|
||||||
|
const catDBody = [
|
||||||
|
"Your Tailscale subscription payment of $6.00 was unsuccessful.",
|
||||||
|
"Please update your payment method at https://login.tailscale.com/billing.",
|
||||||
|
"",
|
||||||
|
"— Tailscale Billing",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const makeHeaders = (from: string, subject: string, date: string) => [
|
||||||
|
{ name: "From", value: from },
|
||||||
|
{ name: "Subject", value: subject },
|
||||||
|
{ name: "Date", value: date },
|
||||||
|
{ name: "To", value: "steven@sjdev.co" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOCK_MESSAGE_LIST = {
|
||||||
|
data: {
|
||||||
|
messages: [
|
||||||
|
{ id: "msg-cat-a-001" },
|
||||||
|
{ id: "msg-cat-b-001" },
|
||||||
|
{ id: "msg-cat-c-001" },
|
||||||
|
{ id: "msg-cat-d-001" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_MESSAGES: Record<string, { data: any }> = {
|
||||||
|
"msg-cat-a-001": {
|
||||||
|
data: {
|
||||||
|
id: "msg-cat-a-001",
|
||||||
|
snippet: "Thank you for your interest in the Senior Software Engineer position...",
|
||||||
|
payload: {
|
||||||
|
headers: makeHeaders(
|
||||||
|
"Acme Talent <talent@acmecorp.com>",
|
||||||
|
"Application Received — Senior Software Engineer",
|
||||||
|
"Mon, 17 Feb 2026 10:00:00 +0000"
|
||||||
|
),
|
||||||
|
mimeType: "multipart/alternative",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
mimeType: "text/plain",
|
||||||
|
body: { data: toBase64Url(catABody) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mimeType: "text/html",
|
||||||
|
body: { data: toBase64Url(`<p>${catABody}</p>`) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"msg-cat-b-001": {
|
||||||
|
data: {
|
||||||
|
id: "msg-cat-b-001",
|
||||||
|
snippet: "We'd love to schedule a phone screen...",
|
||||||
|
payload: {
|
||||||
|
headers: makeHeaders(
|
||||||
|
"Jane Smith <jane.smith@globexcorp.com>",
|
||||||
|
"Phone Screen — Full Stack Engineer at Globex Corp",
|
||||||
|
"Tue, 18 Feb 2026 14:30:00 +0000"
|
||||||
|
),
|
||||||
|
mimeType: "multipart/alternative",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
mimeType: "text/plain",
|
||||||
|
body: { data: toBase64Url(catBBody) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"msg-cat-c-001": {
|
||||||
|
data: {
|
||||||
|
id: "msg-cat-c-001",
|
||||||
|
snippet: "We have decided to pursue other candidates...",
|
||||||
|
payload: {
|
||||||
|
headers: makeHeaders(
|
||||||
|
"Initech Recruiting <recruiting@initech.com>",
|
||||||
|
"Update on Your Application — Platform Engineer",
|
||||||
|
"Wed, 19 Feb 2026 09:15:00 +0000"
|
||||||
|
),
|
||||||
|
mimeType: "text/plain",
|
||||||
|
body: { data: toBase64Url(catCBody) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"msg-cat-d-001": {
|
||||||
|
data: {
|
||||||
|
id: "msg-cat-d-001",
|
||||||
|
snippet: "Your Tailscale subscription payment of $6.00 was unsuccessful.",
|
||||||
|
payload: {
|
||||||
|
headers: makeHeaders(
|
||||||
|
"Tailscale Billing <billing@tailscale.com>",
|
||||||
|
"$6.00 payment to Tailscale US Inc. was unsuccessful",
|
||||||
|
"Thu, 20 Feb 2026 17:00:00 +0000"
|
||||||
|
),
|
||||||
|
mimeType: "text/plain",
|
||||||
|
body: { data: toBase64Url(catDBody) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_EMPTY_LIST = {
|
||||||
|
data: { messages: undefined },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Raw body text for assertion comparisons
|
||||||
|
export const RAW_BODIES = {
|
||||||
|
"msg-cat-a-001": catABody,
|
||||||
|
"msg-cat-b-001": catBBody,
|
||||||
|
"msg-cat-c-001": catCBody,
|
||||||
|
"msg-cat-d-001": catDBody,
|
||||||
|
};
|
||||||
287
test/integration/email-workflow.test.ts
Normal file
287
test/integration/email-workflow.test.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||||
|
import { MOCK_MESSAGE_LIST, MOCK_MESSAGES, MOCK_EMPTY_LIST } from "../fixtures/mock-emails.js";
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Mock the loaders module so no real credentials/tokens/APIs are needed.
|
||||||
|
// vi.mock is hoisted — runs before any module imports.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
const mockGmailList = vi.fn();
|
||||||
|
const mockGmailGet = vi.fn();
|
||||||
|
const mockGmailTrash = vi.fn();
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
vi.mock("../../src/loaders/prompt-config-loaders.js", () => {
|
||||||
|
const testAccounts = {
|
||||||
|
work: { label: "test@test.com", tokenFile: "token.json" },
|
||||||
|
secondary: { label: "test2@test.com", tokenFile: "token-secondary.json" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
PROJECT_ROOT: "/tmp/test-project",
|
||||||
|
VALID_ACCOUNTS: ["work", "secondary"] as const,
|
||||||
|
accounts: testAccounts,
|
||||||
|
accountSchema: z
|
||||||
|
.enum(["work", "secondary"])
|
||||||
|
.describe("test account"),
|
||||||
|
loadAccounts: () => testAccounts,
|
||||||
|
getTokenPath: () => "/tmp/fake-token.json",
|
||||||
|
getSummaryPath: (account: string) => {
|
||||||
|
const base = tmpDir || os.tmpdir();
|
||||||
|
return account === "work"
|
||||||
|
? path.join(base, "mailSummaries", "summary.json")
|
||||||
|
: path.join(base, "mailSummaries", `summary-${account}.json`);
|
||||||
|
},
|
||||||
|
loadClassificationPrompt: () => "CLASSIFY EACH EMAIL AS A, B, C, OR D.",
|
||||||
|
loadActionPrompt: () => "PHASE 2 INSTRUCTIONS HERE.",
|
||||||
|
getGmailClient: () => ({
|
||||||
|
users: {
|
||||||
|
messages: {
|
||||||
|
list: mockGmailList,
|
||||||
|
get: mockGmailGet,
|
||||||
|
trash: mockGmailTrash,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getSheetsClient: vi.fn(),
|
||||||
|
getCalendarClient: vi.fn(),
|
||||||
|
getSpreadsheetId: vi.fn(),
|
||||||
|
getCalendarId: () => "primary",
|
||||||
|
getOAuth2Client: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now import the server and tool modules (they'll use the mocked loaders)
|
||||||
|
const { server } = await import("../../src/McpServer.js");
|
||||||
|
await import("../../src/tools/tools-email.js");
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
let client: Client;
|
||||||
|
|
||||||
|
async function connectClient(): Promise<void> {
|
||||||
|
client = new Client({ name: "test-client", version: "1.0.0" });
|
||||||
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||||
|
await Promise.all([
|
||||||
|
client.connect(clientTransport),
|
||||||
|
server.connect(serverTransport),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callTool(name: string, args: Record<string, unknown>) {
|
||||||
|
const result = await client.callTool({ name, arguments: args });
|
||||||
|
const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? "";
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test suite
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe("Phase 1: Email Review Workflow", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-test-"));
|
||||||
|
fs.mkdirSync(path.join(tmpDir, "mailSummaries"), { recursive: true });
|
||||||
|
await connectClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await client?.close();
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- Step 1: Fetch new emails -----
|
||||||
|
describe("fetch_new_emails", () => {
|
||||||
|
it("returns formatted emails with classification instructions", async () => {
|
||||||
|
mockGmailList.mockResolvedValue(MOCK_MESSAGE_LIST);
|
||||||
|
mockGmailGet.mockImplementation(({ id }: { id: string }) =>
|
||||||
|
Promise.resolve(MOCK_MESSAGES[id])
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = await callTool("fetch_new_emails", {
|
||||||
|
account: "work",
|
||||||
|
maxResults: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Found 4 unread email(s)");
|
||||||
|
|
||||||
|
// All 4 messages present
|
||||||
|
expect(text).toContain("msg-cat-a-001");
|
||||||
|
expect(text).toContain("msg-cat-b-001");
|
||||||
|
expect(text).toContain("msg-cat-c-001");
|
||||||
|
expect(text).toContain("msg-cat-d-001");
|
||||||
|
|
||||||
|
// Headers extracted correctly
|
||||||
|
expect(text).toContain("FROM: Acme Talent <talent@acmecorp.com>");
|
||||||
|
expect(text).toContain("SUBJECT: Phone Screen — Full Stack Engineer at Globex Corp");
|
||||||
|
expect(text).toContain("FROM: Initech Recruiting <recruiting@initech.com>");
|
||||||
|
|
||||||
|
// Body content decoded
|
||||||
|
expect(text).toContain("We have received your application");
|
||||||
|
expect(text).toContain("schedule a phone screen");
|
||||||
|
expect(text).toContain("decided to pursue other candidates");
|
||||||
|
expect(text).toContain("payment of $6.00 was unsuccessful");
|
||||||
|
|
||||||
|
// Classification instructions appended
|
||||||
|
expect(text).toContain("CLASSIFICATION INSTRUCTIONS (PHASE 1)");
|
||||||
|
expect(text).toContain("CLASSIFY EACH EMAIL AS A, B, C, OR D.");
|
||||||
|
|
||||||
|
// Phase 2 instructions also appended
|
||||||
|
expect(text).toContain("ACTION INSTRUCTIONS (PHASE 2)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an empty inbox", async () => {
|
||||||
|
mockGmailList.mockResolvedValue(MOCK_EMPTY_LIST);
|
||||||
|
|
||||||
|
const text = await callTool("fetch_new_emails", {
|
||||||
|
account: "work",
|
||||||
|
maxResults: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toBe("No unread emails found.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Gmail API errors gracefully", async () => {
|
||||||
|
mockGmailList.mockRejectedValue(new Error("Token expired"));
|
||||||
|
|
||||||
|
const text = await callTool("fetch_new_emails", {
|
||||||
|
account: "work",
|
||||||
|
maxResults: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Error fetching emails");
|
||||||
|
expect(text).toContain("Token expired");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- Step 2: Delete A and C emails -----
|
||||||
|
describe("delete_emails", () => {
|
||||||
|
it("trashes the specified message IDs", async () => {
|
||||||
|
mockGmailTrash.mockResolvedValue({});
|
||||||
|
|
||||||
|
const text = await callTool("delete_emails", {
|
||||||
|
account: "work",
|
||||||
|
messageIds: ["msg-cat-a-001", "msg-cat-c-001"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGmailTrash).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockGmailTrash).toHaveBeenCalledWith({ userId: "me", id: "msg-cat-a-001" });
|
||||||
|
expect(mockGmailTrash).toHaveBeenCalledWith({ userId: "me", id: "msg-cat-c-001" });
|
||||||
|
expect(text).toContain("Trashed: msg-cat-a-001");
|
||||||
|
expect(text).toContain("Trashed: msg-cat-c-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports partial failures without aborting", async () => {
|
||||||
|
mockGmailTrash
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
.mockRejectedValueOnce(new Error("Not Found"));
|
||||||
|
|
||||||
|
const text = await callTool("delete_emails", {
|
||||||
|
account: "work",
|
||||||
|
messageIds: ["msg-cat-a-001", "msg-bad-id"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Trashed: msg-cat-a-001");
|
||||||
|
expect(text).toContain("Failed to trash msg-bad-id: Not Found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles auth/client errors", async () => {
|
||||||
|
// Make getGmailClient itself throw by having list throw before any trash
|
||||||
|
mockGmailTrash.mockRejectedValue(new Error("Auth failed"));
|
||||||
|
|
||||||
|
const text = await callTool("delete_emails", {
|
||||||
|
account: "work",
|
||||||
|
messageIds: ["msg-cat-a-001"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Failed to trash msg-cat-a-001: Auth failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- Step 3: Summarize B and D -----
|
||||||
|
describe("append_to_summary", () => {
|
||||||
|
it("creates a summary file and appends entries", async () => {
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
senderName: "Jane Smith",
|
||||||
|
senderEmail: "jane.smith@globexcorp.com",
|
||||||
|
dateReceived: "Tue, 18 Feb 2026 14:30:00 +0000",
|
||||||
|
subject: "Phone Screen — Full Stack Engineer at Globex Corp",
|
||||||
|
category: "B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
senderName: "Tailscale Billing",
|
||||||
|
senderEmail: "billing@tailscale.com",
|
||||||
|
dateReceived: "Thu, 20 Feb 2026 17:00:00 +0000",
|
||||||
|
subject: "$6.00 payment to Tailscale US Inc. was unsuccessful",
|
||||||
|
category: "D",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const text = await callTool("append_to_summary", {
|
||||||
|
account: "work",
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Appended 2 entry/entries to summary.");
|
||||||
|
expect(text).toContain("Total entries in summary: 2");
|
||||||
|
|
||||||
|
const summaryPath = path.join(tmpDir, "mailSummaries", "summary.json");
|
||||||
|
const written = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
|
||||||
|
expect(written).toHaveLength(2);
|
||||||
|
expect(written[0].senderName).toBe("Jane Smith");
|
||||||
|
expect(written[0].category).toBe("B");
|
||||||
|
expect(written[1].category).toBe("D");
|
||||||
|
expect(written[0].addedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges entries older than 30 days", async () => {
|
||||||
|
const summaryPath = path.join(tmpDir, "mailSummaries", "summary.json");
|
||||||
|
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
// Seed with an old entry
|
||||||
|
fs.writeFileSync(
|
||||||
|
summaryPath,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
senderName: "Old Entry",
|
||||||
|
senderEmail: "old@example.com",
|
||||||
|
dateReceived: "2025-01-01",
|
||||||
|
subject: "Ancient email",
|
||||||
|
category: "D",
|
||||||
|
addedAt: oldDate,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = await callTool("append_to_summary", {
|
||||||
|
account: "work",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
senderName: "New Entry",
|
||||||
|
senderEmail: "new@example.com",
|
||||||
|
dateReceived: "2026-02-18",
|
||||||
|
subject: "Fresh email",
|
||||||
|
category: "B",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Purged 1 entry/entries older than 30 days.");
|
||||||
|
expect(text).toContain("Total entries in summary: 1");
|
||||||
|
|
||||||
|
const written = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
|
||||||
|
expect(written).toHaveLength(1);
|
||||||
|
expect(written[0].senderName).toBe("New Entry");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
86
test/unit/email-parsing.test.ts
Normal file
86
test/unit/email-parsing.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { getHeader, decodeBody } from "../../src/tools/tools-email.js";
|
||||||
|
import { MOCK_MESSAGES, RAW_BODIES } from "../fixtures/mock-emails.js";
|
||||||
|
|
||||||
|
describe("getHeader", () => {
|
||||||
|
const headers = [
|
||||||
|
{ name: "From", value: "Jane <jane@example.com>" },
|
||||||
|
{ name: "Subject", value: "Hello World" },
|
||||||
|
{ name: "Date", value: "Mon, 17 Feb 2026 10:00:00 +0000" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("extracts a header by name (case-insensitive)", () => {
|
||||||
|
expect(getHeader(headers, "from")).toBe("Jane <jane@example.com>");
|
||||||
|
expect(getHeader(headers, "FROM")).toBe("Jane <jane@example.com>");
|
||||||
|
expect(getHeader(headers, "From")).toBe("Jane <jane@example.com>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for missing header", () => {
|
||||||
|
expect(getHeader(headers, "Cc")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for undefined headers array", () => {
|
||||||
|
expect(getHeader(undefined, "From")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty headers array", () => {
|
||||||
|
expect(getHeader([], "From")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decodeBody", () => {
|
||||||
|
it("decodes a multipart message (text/plain preferred)", () => {
|
||||||
|
const msg = MOCK_MESSAGES["msg-cat-a-001"].data;
|
||||||
|
const body = decodeBody(msg);
|
||||||
|
expect(body).toBe(RAW_BODIES["msg-cat-a-001"]);
|
||||||
|
expect(body).toContain("We have received your application");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes a multipart message with only text/plain part", () => {
|
||||||
|
const msg = MOCK_MESSAGES["msg-cat-b-001"].data;
|
||||||
|
const body = decodeBody(msg);
|
||||||
|
expect(body).toBe(RAW_BODIES["msg-cat-b-001"]);
|
||||||
|
expect(body).toContain("schedule a phone screen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes a single-part message (no parts array)", () => {
|
||||||
|
const msg = MOCK_MESSAGES["msg-cat-c-001"].data;
|
||||||
|
const body = decodeBody(msg);
|
||||||
|
expect(body).toBe(RAW_BODIES["msg-cat-c-001"]);
|
||||||
|
expect(body).toContain("decided to pursue other candidates");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to snippet when body data is empty", () => {
|
||||||
|
const msg = {
|
||||||
|
snippet: "This is a snippet fallback",
|
||||||
|
payload: { body: { data: "" } },
|
||||||
|
};
|
||||||
|
expect(decodeBody(msg)).toBe("This is a snippet fallback");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to snippet when payload has no body data at all", () => {
|
||||||
|
const msg = {
|
||||||
|
snippet: "Snippet only",
|
||||||
|
payload: {},
|
||||||
|
};
|
||||||
|
expect(decodeBody(msg)).toBe("Snippet only");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string when no body and no snippet", () => {
|
||||||
|
const msg = { payload: {} };
|
||||||
|
expect(decodeBody(msg)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to text/html when text/plain is missing in multipart", () => {
|
||||||
|
const htmlContent = "<p>Hello from HTML</p>";
|
||||||
|
const encoded = Buffer.from(htmlContent).toString("base64url");
|
||||||
|
const msg = {
|
||||||
|
payload: {
|
||||||
|
parts: [
|
||||||
|
{ mimeType: "text/html", body: { data: encoded } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(decodeBody(msg)).toBe(htmlContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,5 +11,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "test", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["test/**/*.test.ts"],
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user