Compare commits
50 Commits
restructur
...
dac6f37331
| Author | SHA1 | Date | |
|---|---|---|---|
| dac6f37331 | |||
|
|
54c6f7e81b | ||
|
|
55d0e77788 | ||
| aaef1b31c4 | |||
| c76725b111 | |||
| e88a0226b6 | |||
| cfef93a374 | |||
| 48017d958e | |||
| 7a6112479f | |||
|
|
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 |
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, meeting, teleconference and event triage and workflow automation via the Model Context Protocol**
|
||||||
|
|
||||||
Infinitely mod-able. Dead simple. Privacy centric.
|
deClawed is an MCP server that connects email inbox to an LLM-capable client (such as Claude Desktop), within an orchestration harness: Agents have access to tools to classify incoming emails, automate routine actions (delete, archive, summarize). The framwork integrates with Google Mail, Sheets and Calendar to keep your 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,20 @@ import { google, gmail_v1, sheets_v4, calendar_v3 } from "googleapis";
|
|||||||
import { OAuth2Client } from "google-auth-library";
|
import { OAuth2Client } from "google-auth-library";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Paths
|
// Paths
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
export const PROJECT_ROOT = path.resolve(__dirname, "..", "..");
|
export 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 CLASSIFY_PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt");
|
const ACCOUNTS_PATH = path.join(SECRETS_DIR, "accounts.json");
|
||||||
const ACTION_PROMPT_PATH = path.join(
|
const PROMPTS_DIR = path.join(PROJECT_ROOT, "src", "prompts");
|
||||||
PROJECT_ROOT, "src", "prompts", "take-action-on-emails.txt"
|
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
|
// Account configuration
|
||||||
@@ -46,14 +48,14 @@ export const getTokenPath = (account: string): string => {
|
|||||||
`Unknown account "${account}". Available accounts: ${available}`
|
`Unknown account "${account}". Available accounts: ${available}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return path.join(PROJECT_ROOT, acct.tokenFile);
|
return path.join(SECRETS_DIR, acct.tokenFile);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSummaryPath = (account: string): string => {
|
export const getSummaryPath = (account: string): string => {
|
||||||
if (account === "work") {
|
if (account === "work") {
|
||||||
return path.join(PROJECT_ROOT, "summary.json");
|
return path.join(PROJECT_ROOT, "mailSummaries", "summary.json");
|
||||||
}
|
}
|
||||||
return path.join(PROJECT_ROOT, `summary-${account}.json`);
|
return path.join(PROJECT_ROOT, "mailSummaries", `summary-${account}.json`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VALID_ACCOUNTS = ["work", "secondary"] as const;
|
export const VALID_ACCOUNTS = ["work", "secondary"] as const;
|
||||||
|
|||||||
@@ -6,27 +6,23 @@ import {
|
|||||||
} from "../loaders/prompt-config-loaders.js";
|
} from "../loaders/prompt-config-loaders.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Prompt: review_emails (work account)
|
// Prompt: Classify Emails (Phase 1)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
server.registerPrompt(
|
server.registerPrompt(
|
||||||
"review_emails",
|
"classify-emails",
|
||||||
{
|
{
|
||||||
description:
|
description: "Phase 1: Organize, prioritize, and summarize incoming recruiter emails.",
|
||||||
`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 phase1 = loadClassificationPrompt();
|
||||||
const phase2 = loadActionPrompt();
|
|
||||||
return {
|
return {
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user" as const,
|
role: "user" as const,
|
||||||
content: {
|
content: {
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text:
|
text: `ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
|
||||||
`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.")
|
||||||
(phase1 || "Review my new emails and classify them by job application category.") +
|
|
||||||
(phase2 ? `\n\n${phase2}` : ""),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -35,16 +31,14 @@ server.registerPrompt(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Prompt: review_secondary_emails (secondary account)
|
// Prompt: Take Action on Emails (Phase 2)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
server.registerPrompt(
|
server.registerPrompt(
|
||||||
"review_secondary_emails",
|
"take-action-on-emails",
|
||||||
{
|
{
|
||||||
description:
|
description: "Phase 2: Log Category B recruiters to spreadsheet and create calendar events.",
|
||||||
`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();
|
const phase2 = loadActionPrompt();
|
||||||
return {
|
return {
|
||||||
messages: [
|
messages: [
|
||||||
@@ -52,10 +46,8 @@ server.registerPrompt(
|
|||||||
role: "user" as const,
|
role: "user" as const,
|
||||||
content: {
|
content: {
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text:
|
text: `ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
|
||||||
`ACCOUNT: Use account = "secondary" for ALL tool calls in this session.\n\n` +
|
(phase2 || "Process remaining action items for flagged recruiters.")
|
||||||
(phase1 || "Review my new emails and classify them by job application category.") +
|
|
||||||
(phase2 ? `\n\n${phase2}` : ""),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
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.
|
||||||
@@ -9,23 +9,28 @@ 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." No next steps are requested.
|
||||||
|
Action you should take: Add a "Star" using the gmail built-in actions.
|
||||||
|
|
||||||
Category B - Advancement to Next Step
|
Category B - Advancement to 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 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.
|
||||||
|
Actions you should take: 1) add a calendar event with all pertinent info: a) person I will be meeting with,
|
||||||
|
b) company c) role I will be discussing d) method for joining meeting - ie, telephone? If yes, will the representative all me, and is the number correct (718) 749-8292 c) or, is it Zoom, Google Meet, Webex or another video format, if so, include the link for joining
|
||||||
|
|
||||||
|
NEVER, EVER ADD A STAR TO A VATEGORY B EMAIL.
|
||||||
|
|
||||||
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."
|
||||||
|
Action you should take:
|
||||||
|
Add a "Star" using the gmail built-in actions.
|
||||||
|
|
||||||
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.
|
||||||
|
Action you should take: Add a "Star" using the gmail built-in actions.
|
||||||
|
|
||||||
STEP 3: DELETE emails classified as Category A and Category C.
|
STEP 3: SUMMARIZE emails classified as Category B and Category D in the chat in an organized list with headings. Example format for this:
|
||||||
|
|
||||||
STEP 4: ("Anti step") - NEVER DELETE Category B emails.
|
|
||||||
|
|
||||||
STEP 5: SUMMARIZE emails classified as Category B and Category D. For each, include:
|
|
||||||
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. Suggested action I might take
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Email parsing helpers
|
// Email parsing helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const getHeader = (
|
export const getHeader = (
|
||||||
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
|
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
|
||||||
name: string
|
name: string
|
||||||
): string => {
|
): string => {
|
||||||
@@ -24,7 +24,7 @@ const getHeader = (
|
|||||||
return header?.value ?? "";
|
return header?.value ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const decodeBody = (message: gmail_v1.Schema$Message): string => {
|
export const decodeBody = (message: gmail_v1.Schema$Message): string => {
|
||||||
const parts = message.payload?.parts;
|
const parts = message.payload?.parts;
|
||||||
let encoded = "";
|
let encoded = "";
|
||||||
|
|
||||||
@@ -56,14 +56,14 @@ server.registerTool(
|
|||||||
description:
|
description:
|
||||||
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
|
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
|
||||||
"subject, message ID, and body text for each message. The message IDs " +
|
"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.",
|
"must be passed to star_emails after evaluation. Specify which account to fetch from.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
account: accountSchema,
|
account: accountSchema,
|
||||||
maxResults: z
|
maxResults: z
|
||||||
.number()
|
.number()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(100)
|
.max(200)
|
||||||
.describe("Maximum number of unread emails to fetch (1-100)"),
|
.describe("Maximum number of unread emails to fetch (1-200)"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ account, maxResults }) => {
|
async ({ account, maxResults }) => {
|
||||||
@@ -303,3 +303,58 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// 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}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -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