Compare commits
60 Commits
sec
...
FEAT-Add-S
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25ac1ec68f | ||
|
|
e7feb61e6f | ||
| 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 | ||
|
|
94b07531d0 | ||
|
|
1094f9c972 | ||
|
|
68e48b2345 | ||
|
|
28a368326d | ||
|
|
50214fb4e5 | ||
|
|
dc1fdecfcd | ||
|
|
e66eea75dc | ||
|
|
54d944b9fb |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,12 +1,12 @@
|
||||
node_modules/
|
||||
build/
|
||||
|
||||
# OAuth secrets
|
||||
credentials.json
|
||||
credentials.*
|
||||
token*.json
|
||||
accounts.json
|
||||
# OAuth secrets (inside accountsAndCredentials/, example files are NOT ignored)
|
||||
accountsAndCredentials/credentials.json
|
||||
accountsAndCredentials/accounts.json
|
||||
accountsAndCredentials/token*.json
|
||||
accountsAndCredentials/token.json.bak
|
||||
!accountsAndCredentials/*.example.json
|
||||
|
||||
# Runtime output
|
||||
summary.json
|
||||
summary-secondary.json
|
||||
mailSummaries/
|
||||
|
||||
356
README.md
356
README.md
@@ -1,272 +1,194 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
git clone https://git.sjdev.online/kjannette/deClawed-Assity-Kitty
|
||||
cd deClawed-Assity-Kitty
|
||||
npm install
|
||||
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:**
|
||||
- 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
|
||||
Create `accountsAndCredentials/accounts.json`:
|
||||
|
||||
**File location:** Must be at the project root as `classify-emails.txt`.
|
||||
|
||||
### 10. Configure Claude Desktop
|
||||
|
||||
Edit the Claude Desktop config file:
|
||||
|
||||
```bash
|
||||
code ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
||||
```json
|
||||
{
|
||||
"work": {
|
||||
"label": "you@example.com",
|
||||
"tokenFile": "token.json",
|
||||
"spreadsheetId": "YOUR_GOOGLE_SHEET_ID",
|
||||
"calendarId": "primary"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
{
|
||||
"mcpServers": {
|
||||
"assistant": {
|
||||
"command": "/ABSOLUTE/PATH/TO/node",
|
||||
"args": [
|
||||
"/Users/kjannette/assistant/build/index.js"
|
||||
]
|
||||
"command": "/path/to/node",
|
||||
"args": ["/path/to/deClawed-Assity-Kitty/build/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace `/ABSOLUTE/PATH/TO/node` with the output of `which node`.
|
||||
|
||||
### 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.
|
||||
Restart Claude Desktop. The server appears under **Connectors**.
|
||||
|
||||
---
|
||||
|
||||
## 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 | Star and mark read |
|
||||
| **B** | Advancement (interview requests, next steps) | Log + Calendar event (never star) |
|
||||
| **C** | Rejections | Star and mark read |
|
||||
| **D** | Other/uncategorized | Star, mark read, and log for review |
|
||||
|
||||
### Running the Workflow
|
||||
### Customizing Prompts
|
||||
|
||||
1. Open Claude Desktop
|
||||
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`
|
||||
Edit the plain-text files in `src/prompts/` to adjust classification rules and actions:
|
||||
|
||||
### 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 Category A/C/D 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: Star and Mark Read (Categories A, C, D only)
|
||||
|
||||
```
|
||||
star_emails(account: "work", messageIds: ["msg-id-1", "msg-id-2"])
|
||||
```
|
||||
|
||||
### 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 |
|
||||
|---------|---------|
|
||||
| `npm run build` | Recompile after editing `src/index.ts` |
|
||||
| `npm run auth` | Re-authorize Gmail (only if `token.json` deleted/expired) |
|
||||
| Cmd+Q Claude Desktop, reopen | Pick up server changes after a rebuild |
|
||||
| `npm run build` | Compile TypeScript to `build/` |
|
||||
| `npm run auth` | Authorize the default account |
|
||||
| `npm run auth -- <name>` | Authorize a named account |
|
||||
| `npm test` | Run test suite |
|
||||
| `npm run test:watch` | Run tests in watch mode |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
assistant/
|
||||
deClawed-Assity-Kitty/
|
||||
├── src/
|
||||
│ ├── index.ts # MCP server source (tools + prompt)
|
||||
│ └── auth.ts # One-time OAuth setup script
|
||||
├── build/
|
||||
│ ├── index.js # Compiled server (Claude Desktop runs this)
|
||||
│ └── auth.js # Compiled auth script
|
||||
├── classify-emails.txt # Classification prompt (plain text, edit anytime)
|
||||
├── credentials.json # Google OAuth client credentials (from Cloud Console)
|
||||
├── token.json # Gmail access/refresh token (auto-generated)
|
||||
├── summary.json # Output file where B/D emails are logged
|
||||
├── package.json # Project config and scripts
|
||||
├── tsconfig.json # TypeScript compiler config
|
||||
└── README.md # This file
|
||||
│ ├── index.ts # Entry point
|
||||
│ ├── McpServer.ts # MCP server instance
|
||||
│ ├── auth.ts # OAuth setup script
|
||||
│ ├── loaders/ # Config and prompt loaders
|
||||
│ ├── prompts/ # Classification and action prompts
|
||||
│ └── tools/ # MCP tool implementations
|
||||
├── accountsAndCredentials/ # OAuth credentials and tokens (gitignored)
|
||||
├── mailSummaries/ # Local email logs (gitignored)
|
||||
├── test/ # Unit and integration tests
|
||||
└── build/ # Compiled output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 |
@@ -1,30 +0,0 @@
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
STEP 1: Constraints
|
||||
- Do not make up information.
|
||||
|
||||
STEP 2: CLASSIFY each email into exactly one category.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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."
|
||||
|
||||
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.
|
||||
|
||||
STEP 3: DELETE emails classified as Category A and Category C.
|
||||
|
||||
STEP 4: SUMMARIZE emails classified as Category B and Category D. For each, include:
|
||||
1. Sender name and email address
|
||||
2. Email subject line
|
||||
3. Date and time received
|
||||
4. Category (B or D)
|
||||
5. Suggested action I should take
|
||||
|
||||
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": {
|
||||
"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": [
|
||||
"build"
|
||||
@@ -17,12 +19,13 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"googleapis": "^171.4.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
});
|
||||
57
src/auth.ts
57
src/auth.ts
@@ -5,21 +5,47 @@ import readline from "readline";
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
||||
const CREDENTIALS_PATH = path.join(PROJECT_ROOT, "credentials.json");
|
||||
const TOKEN_PATH = path.join(PROJECT_ROOT, "token.json");
|
||||
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");
|
||||
|
||||
// Gmail modify scope: read, send, delete, and manage labels
|
||||
const SCOPES = ["https://www.googleapis.com/auth/gmail.modify"];
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
];
|
||||
|
||||
function resolveTokenPath(accountKey: string): string {
|
||||
if (!fs.existsSync(ACCOUNTS_PATH)) {
|
||||
throw new Error(`Missing ${ACCOUNTS_PATH}. Create accounts.json first.`);
|
||||
}
|
||||
const accounts = JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
|
||||
const acct = accounts[accountKey];
|
||||
if (!acct) {
|
||||
const available = Object.keys(accounts).join(", ");
|
||||
throw new Error(
|
||||
`Unknown account "${accountKey}". Available: ${available}`
|
||||
);
|
||||
}
|
||||
return path.join(SECRETS_DIR, acct.tokenFile);
|
||||
}
|
||||
|
||||
async function authorize(): Promise<void> {
|
||||
const accountKey = process.argv[2] || "work";
|
||||
const tokenPath = resolveTokenPath(accountKey);
|
||||
|
||||
console.log(`Authorizing account: "${accountKey}"`);
|
||||
console.log(`Token file: ${tokenPath}`);
|
||||
console.log(`Scopes: ${SCOPES.join(", ")}\n`);
|
||||
|
||||
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
||||
console.error(
|
||||
`Missing ${CREDENTIALS_PATH}\n\n` +
|
||||
"To create this file:\n" +
|
||||
"1. Go to https://console.cloud.google.com/\n" +
|
||||
"2. Create a project and enable the Gmail API\n" +
|
||||
"2. Create a project and enable the Gmail, Sheets, and Calendar APIs\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);
|
||||
}
|
||||
@@ -34,16 +60,18 @@ async function authorize(): Promise<void> {
|
||||
redirect_uris[0]
|
||||
);
|
||||
|
||||
// Check if we already have a valid token
|
||||
if (fs.existsSync(TOKEN_PATH)) {
|
||||
const token = JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8"));
|
||||
if (fs.existsSync(tokenPath)) {
|
||||
const token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
||||
oAuth2Client.setCredentials(token);
|
||||
console.log("Token already exists at:", TOKEN_PATH);
|
||||
console.log("To re-authorize, delete token.json and run this script again.");
|
||||
console.log("Token already exists at:", tokenPath);
|
||||
console.log(
|
||||
"To re-authorize with new scopes, delete the token file and run again:\n" +
|
||||
` rm ${tokenPath}\n` +
|
||||
` npm run auth -- ${accountKey}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate auth URL and prompt user
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: SCOPES,
|
||||
@@ -69,7 +97,6 @@ async function authorize(): Promise<void> {
|
||||
);
|
||||
});
|
||||
|
||||
// Extract the code whether they pasted the full URL or just the code
|
||||
let code = rawInput;
|
||||
if (rawInput.includes("code=")) {
|
||||
const url = new URL(rawInput);
|
||||
@@ -81,8 +108,8 @@ async function authorize(): Promise<void> {
|
||||
const { tokens } = await oAuth2Client.getToken(code);
|
||||
oAuth2Client.setCredentials(tokens);
|
||||
|
||||
fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2));
|
||||
console.log("\nToken saved to:", TOKEN_PATH);
|
||||
fs.writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
|
||||
console.log("\nToken saved to:", tokenPath);
|
||||
console.log("You can now start the MCP server.");
|
||||
}
|
||||
|
||||
|
||||
488
src/index.ts
488
src/index.ts
@@ -1,488 +1,18 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
#!/usr/bin/env node
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { google, gmail_v1 } from "googleapis";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { server } from "./McpServer.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths — resolved relative to the compiled build/ directory, up one level
|
||||
// to the project root where credentials.json, token.json, and summary.json live.
|
||||
// ---------------------------------------------------------------------------
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
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 PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt");
|
||||
// Side-effect imports: each module registers its prompts/tools on the server
|
||||
import "./prompt-controller-service/prompt-controller-service.js";
|
||||
import "./tools/tools-email.js";
|
||||
import "./tools/tools-spreadsheet.js";
|
||||
import "./tools/tools-calendar.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
interface AccountConfig {
|
||||
label: string;
|
||||
tokenFile: 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}`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load classification prompt
|
||||
// ---------------------------------------------------------------------------
|
||||
function loadClassificationPrompt(): string {
|
||||
if (!fs.existsSync(PROMPT_PATH)) {
|
||||
console.error(`Warning: ${PROMPT_PATH} not found. Classification instructions will be missing.`);
|
||||
return "";
|
||||
}
|
||||
return fs.readFileSync(PROMPT_PATH, "utf-8");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gmail auth helper — parameterized by account key
|
||||
// ---------------------------------------------------------------------------
|
||||
function getGmailClient(account: string): gmail_v1.Gmail {
|
||||
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);
|
||||
|
||||
// Persist refreshed tokens automatically
|
||||
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 google.gmail({ version: "v1", auth: oAuth2Client });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 job application emails (A/B/C/D), delete A+C, summarize B+D.`,
|
||||
},
|
||||
() => {
|
||||
const instructions = 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` +
|
||||
(instructions ||
|
||||
"Review my new emails and classify them by job application category."),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt: review_secondary_emails (secondary account)
|
||||
// ---------------------------------------------------------------------------
|
||||
server.registerPrompt(
|
||||
"review_secondary_emails",
|
||||
{
|
||||
description:
|
||||
`Review SECONDARY inbox (${accounts.secondary?.label ?? "secondary"}): classify job application emails (A/B/C/D), delete A+C, summarize B+D.`,
|
||||
},
|
||||
() => {
|
||||
const instructions = loadClassificationPrompt();
|
||||
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` +
|
||||
(instructions ||
|
||||
"Review my new emails and classify them by job application category."),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 instructionsBlock = classificationInstructions
|
||||
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS:\n${"=".repeat(60)}\n${classificationInstructions}`
|
||||
: "";
|
||||
|
||||
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}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start server
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main(): Promise<void> {
|
||||
const main = async (): Promise<void> => {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Assistant MCP Server running on stdio");
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((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.
|
||||
@@ -9,22 +9,27 @@ STEP 2: CLASSIFY each email into exactly one category.
|
||||
|
||||
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.
|
||||
Action you should take: Call star_emails with this message's ID (same account used for fetch_new_emails). That stars the email and marks it read.
|
||||
|
||||
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.
|
||||
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 CATEGORY B EMAIL. Do not pass Category B message IDs to star_emails.
|
||||
|
||||
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."
|
||||
Action you should take: Call star_emails with this message's ID (same account used for fetch_new_emails). That stars the email and marks it read.
|
||||
|
||||
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.
|
||||
Action you should take: Call star_emails with this message's ID (same account used for fetch_new_emails). That stars the email and marks it read.
|
||||
|
||||
STEP 3: DELETE emails classified as Category A and Category C.
|
||||
|
||||
STEP 4: SUMMARIZE emails classified as Category B and Category D. For each, include:
|
||||
STEP 3: SUMMARIZE emails classified as Category B and Category D in the chat in an organized list with headings. Example format for this:
|
||||
1. Sender name and email address
|
||||
2. Email subject line
|
||||
3. Date and time received
|
||||
4. Category (B or D)
|
||||
5. Suggested action I should take
|
||||
5. Suggested action I might take
|
||||
|
||||
|
||||
23
src/prompts/take-action-on-emails.txt
Normal file
23
src/prompts/take-action-on-emails.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
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).
|
||||
|
||||
STEP 1: CREATE CALENDAR EVENTS
|
||||
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 it does not exist in text within a verifiable email, IT DOES NOT EXIST.
|
||||
- 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.
|
||||
- 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)
|
||||
- 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}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
362
src/tools/tools-email.ts
Normal file
362
src/tools/tools-email.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
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. After evaluation, " +
|
||||
"pass Category A, C, and D message IDs to star_emails. Never pass " +
|
||||
"Category B IDs to star_emails. 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 Category A (acknowledgements), C (rejections), and D (other) " +
|
||||
"messages to signal they have been processed. Never use this for Category B " +
|
||||
"(advancement) emails. 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,
|
||||
};
|
||||
334
test/integration/email-workflow.test.ts
Normal file
334
test/integration/email-workflow.test.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
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();
|
||||
const mockGmailBatchModify = 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,
|
||||
batchModify: mockGmailBatchModify,
|
||||
},
|
||||
},
|
||||
}),
|
||||
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 2b: Star A, C, and D emails -----
|
||||
describe("star_emails", () => {
|
||||
it("stars and marks read the specified message IDs", async () => {
|
||||
mockGmailBatchModify.mockResolvedValue({});
|
||||
|
||||
const text = await callTool("star_emails", {
|
||||
account: "work",
|
||||
messageIds: ["msg-cat-a-001", "msg-cat-c-001", "msg-cat-d-001"],
|
||||
});
|
||||
|
||||
expect(mockGmailBatchModify).toHaveBeenCalledTimes(1);
|
||||
expect(mockGmailBatchModify).toHaveBeenCalledWith({
|
||||
userId: "me",
|
||||
requestBody: {
|
||||
ids: ["msg-cat-a-001", "msg-cat-c-001", "msg-cat-d-001"],
|
||||
addLabelIds: ["STARRED"],
|
||||
removeLabelIds: ["UNREAD"],
|
||||
},
|
||||
});
|
||||
expect(text).toContain("Successfully starred and marked read 3 email(s).");
|
||||
});
|
||||
|
||||
it("returns early when no message IDs are provided", async () => {
|
||||
const text = await callTool("star_emails", {
|
||||
account: "work",
|
||||
messageIds: [],
|
||||
});
|
||||
|
||||
expect(mockGmailBatchModify).not.toHaveBeenCalled();
|
||||
expect(text).toBe("No message IDs provided.");
|
||||
});
|
||||
|
||||
it("handles Gmail API errors gracefully", async () => {
|
||||
mockGmailBatchModify.mockRejectedValue(new Error("Quota exceeded"));
|
||||
|
||||
const text = await callTool("star_emails", {
|
||||
account: "work",
|
||||
messageIds: ["msg-cat-a-001"],
|
||||
});
|
||||
|
||||
expect(text).toContain("Error starring emails");
|
||||
expect(text).toContain("Quota exceeded");
|
||||
});
|
||||
});
|
||||
|
||||
// ----- 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
|
||||
},
|
||||
"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