Compare commits
13 Commits
FEAT-conne
...
FEAT-refin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d17c409be | ||
|
|
f96a8541c6 | ||
|
|
8ad47f1d27 | ||
| 38c7e48a1d | |||
|
|
6817014b7f | ||
| dade7f1fb8 | |||
|
|
632b0029be | ||
| 549f2b0949 | |||
| c1f03a7cbe | |||
|
|
b767a1f09c | ||
| 76d4b490bf | |||
|
|
31618b1acf | ||
| 087839d2bf |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,7 +5,10 @@ build/
|
|||||||
accountsAndCredentials/credentials.json
|
accountsAndCredentials/credentials.json
|
||||||
accountsAndCredentials/accounts.json
|
accountsAndCredentials/accounts.json
|
||||||
accountsAndCredentials/token*.json
|
accountsAndCredentials/token*.json
|
||||||
|
accountsAndCredentials/token.json.bak
|
||||||
!accountsAndCredentials/*.example.json
|
!accountsAndCredentials/*.example.json
|
||||||
|
|
||||||
# Runtime output
|
# Runtime output
|
||||||
mailSummaries/
|
mailSummaries/
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -488,3 +488,4 @@ deClawed-Assity-Kitty/
|
|||||||
├── .gitignore
|
├── .gitignore
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"work": {
|
"work": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"tokenFile": "token.json",
|
"tokenFile": "token.json",
|
||||||
"spreadsheetId": "",
|
"spreadsheetId": "",
|
||||||
"calendarId": "primary"
|
"calendarId": "primary"
|
||||||
},
|
},
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"tokenFile": "token-secondary.json"
|
"tokenFile": "token-secondary.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"installed": {
|
"installed": {
|
||||||
"client_id": "",
|
"client_id": "",
|
||||||
"project_id": "",
|
"project_id": "",
|
||||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
"client_secret": "",
|
"client_secret": "",
|
||||||
"redirect_uris": ["http://localhost"]
|
"redirect_uris": [
|
||||||
}
|
"http://localhost"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"refresh_token": "",
|
"refresh_token": "",
|
||||||
"scope": "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/calendar.events",
|
"scope": "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/calendar.events",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expiry_date": 0
|
"expiry_date": 0
|
||||||
}
|
}
|
||||||
6742
package-lock.json
generated
6742
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
58
package.json
58
package.json
@@ -1,31 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "assistant",
|
"name": "assistant",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "MCP server for Gmail job application management",
|
"description": "MCP server for Gmail job application management",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"assistant": "./build/index.js"
|
"assistant": "./build/index.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && chmod 755 build/index.js",
|
"build": "tsc && chmod 755 build/index.js",
|
||||||
"auth": "npm run build && node build/auth.js",
|
"auth": "npm run build && node build/auth.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"build"
|
"build"
|
||||||
],
|
],
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"googleapis": "^171.4.0",
|
"googleapis": "^171.4.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,3 +4,5 @@ export const server = new McpServer({
|
|||||||
name: "assistant",
|
name: "assistant",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -117,3 +117,5 @@ authorize().catch((err) => {
|
|||||||
console.error("Authorization failed:", err);
|
console.error("Authorization failed:", err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ main().catch((error) => {
|
|||||||
console.error("Fatal error in main():", error);
|
console.error("Fatal error in main():", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { google, gmail_v1, sheets_v4, calendar_v3 } from "googleapis";
|
|||||||
import { OAuth2Client } from "google-auth-library";
|
import { OAuth2Client } from "google-auth-library";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Paths
|
// Paths
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
export const PROJECT_ROOT = path.resolve(__dirname, "..", "..");
|
export const PROJECT_ROOT = path.resolve(__dirname, "..", "..");
|
||||||
const SECRETS_DIR = path.join(PROJECT_ROOT, "accountsAndCredentials");
|
const SECRETS_DIR = path.join(PROJECT_ROOT, "accountsAndCredentials");
|
||||||
const CREDENTIALS_PATH = path.join(SECRETS_DIR, "credentials.json");
|
const CREDENTIALS_PATH = path.join(SECRETS_DIR, "credentials.json");
|
||||||
@@ -20,130 +22,132 @@ const ACTION_PROMPT_PATH = path.join(PROMPTS_DIR, "take-action-on-emails.txt");
|
|||||||
// Account configuration
|
// Account configuration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export interface AccountConfig {
|
export interface AccountConfig {
|
||||||
label: string;
|
label: string;
|
||||||
tokenFile: string;
|
tokenFile: string;
|
||||||
spreadsheetId?: string;
|
spreadsheetId?: string;
|
||||||
calendarId?: string;
|
calendarId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountsMap {
|
export interface AccountsMap {
|
||||||
[key: string]: AccountConfig;
|
[key: string]: AccountConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadAccounts = (): AccountsMap => {
|
export const loadAccounts = (): AccountsMap => {
|
||||||
if (!fs.existsSync(ACCOUNTS_PATH)) {
|
if (!fs.existsSync(ACCOUNTS_PATH)) {
|
||||||
throw new Error(`Missing accounts.json at ${ACCOUNTS_PATH}.`);
|
throw new Error(`Missing accounts.json at ${ACCOUNTS_PATH}.`);
|
||||||
}
|
}
|
||||||
return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
|
return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTokenPath = (account: string): string => {
|
export const getTokenPath = (account: string): string => {
|
||||||
const accts = loadAccounts();
|
const accts = loadAccounts();
|
||||||
const acct = accts[account];
|
const acct = accts[account];
|
||||||
if (!acct) {
|
if (!acct) {
|
||||||
const available = Object.keys(accts).join(", ");
|
const available = Object.keys(accts).join(", ");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown account "${account}". Available accounts: ${available}`
|
`Unknown account "${account}". Available accounts: ${available}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return path.join(SECRETS_DIR, acct.tokenFile);
|
return path.join(SECRETS_DIR, acct.tokenFile);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSummaryPath = (account: string): string => {
|
export const getSummaryPath = (account: string): string => {
|
||||||
if (account === "work") {
|
if (account === "work") {
|
||||||
return path.join(PROJECT_ROOT, "mailSummaries", "summary.json");
|
return path.join(PROJECT_ROOT, "mailSummaries", "summary.json");
|
||||||
}
|
}
|
||||||
return path.join(PROJECT_ROOT, "mailSummaries", `summary-${account}.json`);
|
return path.join(PROJECT_ROOT, "mailSummaries", `summary-${account}.json`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VALID_ACCOUNTS = ["work", "secondary"] as const;
|
export const VALID_ACCOUNTS = ["work", "secondary"] as const;
|
||||||
export const accounts = loadAccounts();
|
export const accounts = loadAccounts();
|
||||||
|
|
||||||
const accountDescription = VALID_ACCOUNTS
|
const accountDescription = VALID_ACCOUNTS
|
||||||
.map((key) => `"${key}" (${accounts[key]?.label ?? key})`)
|
.map((key) => `"${key}" (${accounts[key]?.label ?? key})`)
|
||||||
.join(" or ");
|
.join(" or ");
|
||||||
|
|
||||||
export const accountSchema = z
|
export const accountSchema = z
|
||||||
.enum(VALID_ACCOUNTS)
|
.enum(VALID_ACCOUNTS)
|
||||||
.describe(`Which email account to use: ${accountDescription}`);
|
.describe(`Which email account to use: ${accountDescription}`);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Prompt loaders
|
// Prompt loaders
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const loadPromptFile = (filePath: string, label: string): string => {
|
const loadPromptFile = (filePath: string, label: string): string => {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
console.error(`Warning: ${filePath} not found. ${label} will be missing.`);
|
console.error(`Warning: ${filePath} not found. ${label} will be missing.`);
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return fs.readFileSync(filePath, "utf-8");
|
return fs.readFileSync(filePath, "utf-8");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadClassificationPrompt = (): string =>
|
export const loadClassificationPrompt = (): string =>
|
||||||
loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions");
|
loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions");
|
||||||
|
|
||||||
export const loadActionPrompt = (): string =>
|
export const loadActionPrompt = (): string =>
|
||||||
loadPromptFile(ACTION_PROMPT_PATH, "Action instructions");
|
loadPromptFile(ACTION_PROMPT_PATH, "Action instructions");
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Auth helpers — generic OAuth2 client, then service-specific factories
|
// Auth helpers — generic OAuth2 client, then service-specific factories
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const getOAuth2Client = (account: string): OAuth2Client => {
|
export const getOAuth2Client = (account: string): OAuth2Client => {
|
||||||
const tokenPath = getTokenPath(account);
|
const tokenPath = getTokenPath(account);
|
||||||
|
|
||||||
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Missing credentials.json at ${CREDENTIALS_PATH}. Run "npm run auth" first.`
|
`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]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
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 token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
||||||
const { client_id, client_secret, redirect_uris } =
|
oAuth2Client.setCredentials(token);
|
||||||
credentials.installed || credentials.web;
|
|
||||||
|
|
||||||
const oAuth2Client = new google.auth.OAuth2(
|
oAuth2Client.on("tokens", (newTokens) => {
|
||||||
client_id,
|
const current = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
||||||
client_secret,
|
fs.writeFileSync(
|
||||||
redirect_uris[0]
|
tokenPath,
|
||||||
);
|
JSON.stringify({ ...current, ...newTokens }, null, 2)
|
||||||
|
);
|
||||||
|
console.error(`Token refreshed and saved for account "${account}".`);
|
||||||
|
});
|
||||||
|
|
||||||
const token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
return oAuth2Client;
|
||||||
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 =>
|
export const getGmailClient = (account: string): gmail_v1.Gmail =>
|
||||||
google.gmail({ version: "v1", auth: getOAuth2Client(account) });
|
google.gmail({ version: "v1", auth: getOAuth2Client(account) });
|
||||||
|
|
||||||
export const getSheetsClient = (account: string): sheets_v4.Sheets =>
|
export const getSheetsClient = (account: string): sheets_v4.Sheets =>
|
||||||
google.sheets({ version: "v4", auth: getOAuth2Client(account) });
|
google.sheets({ version: "v4", auth: getOAuth2Client(account) });
|
||||||
|
|
||||||
export const getCalendarClient = (account: string): calendar_v3.Calendar =>
|
export const getCalendarClient = (account: string): calendar_v3.Calendar =>
|
||||||
google.calendar({ version: "v3", auth: getOAuth2Client(account) });
|
google.calendar({ version: "v3", auth: getOAuth2Client(account) });
|
||||||
|
|
||||||
export const getSpreadsheetId = (account: string): string => {
|
export const getSpreadsheetId = (account: string): string => {
|
||||||
const acct = accounts[account];
|
const acct = accounts[account];
|
||||||
if (!acct?.spreadsheetId || acct.spreadsheetId === "PASTE_YOUR_SHEET_ID_HERE") {
|
if (!acct?.spreadsheetId || acct.spreadsheetId === "PASTE_YOUR_SHEET_ID_HERE") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No spreadsheetId configured for account "${account}" in accounts.json.`
|
`No spreadsheetId configured for account "${account}" in accounts.json.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return acct.spreadsheetId;
|
return acct.spreadsheetId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCalendarId = (account: string): string =>
|
export const getCalendarId = (account: string): string =>
|
||||||
accounts[account]?.calendarId ?? "primary";
|
accounts[account]?.calendarId ?? "primary";
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -1,64 +1,58 @@
|
|||||||
import { server } from "../McpServer.js";
|
import { server } from "../McpServer.js";
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
loadClassificationPrompt,
|
loadClassificationPrompt,
|
||||||
loadActionPrompt,
|
loadActionPrompt,
|
||||||
} from "../loaders/prompt-config-loaders.js";
|
} from "../loaders/prompt-config-loaders.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Prompt: review_emails (work account)
|
// Prompt: Classify Emails (Phase 1)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
server.registerPrompt(
|
server.registerPrompt(
|
||||||
"review_emails",
|
"classify-emails",
|
||||||
{
|
{
|
||||||
description:
|
description: "Phase 1: Organize, prioritize, and summarize incoming recruiter emails.",
|
||||||
`Review WORK inbox (${accounts.work?.label ?? "work"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`,
|
},
|
||||||
},
|
() => {
|
||||||
() => {
|
const phase1 = loadClassificationPrompt();
|
||||||
const phase1 = loadClassificationPrompt();
|
return {
|
||||||
const phase2 = loadActionPrompt();
|
messages: [
|
||||||
return {
|
{
|
||||||
messages: [
|
role: "user" as const,
|
||||||
{
|
content: {
|
||||||
role: "user" as const,
|
type: "text" as const,
|
||||||
content: {
|
text: `ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
|
||||||
type: "text" as const,
|
(phase1 || "Review my new emails and classify them by job application category.")
|
||||||
text:
|
},
|
||||||
`ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
|
},
|
||||||
(phase1 || "Review my new emails and classify them by job application category.") +
|
],
|
||||||
(phase2 ? `\n\n${phase2}` : ""),
|
};
|
||||||
},
|
}
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Prompt: review_secondary_emails (secondary account)
|
// Prompt: Take Action on Emails (Phase 2)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
server.registerPrompt(
|
server.registerPrompt(
|
||||||
"review_secondary_emails",
|
"take-action-on-emails",
|
||||||
{
|
{
|
||||||
description:
|
description: "Phase 2: Log Category B recruiters to spreadsheet and create calendar events.",
|
||||||
`Review SECONDARY inbox (${accounts.secondary?.label ?? "secondary"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`,
|
},
|
||||||
},
|
() => {
|
||||||
() => {
|
const phase2 = loadActionPrompt();
|
||||||
const phase1 = loadClassificationPrompt();
|
return {
|
||||||
const phase2 = loadActionPrompt();
|
messages: [
|
||||||
return {
|
{
|
||||||
messages: [
|
role: "user" as const,
|
||||||
{
|
content: {
|
||||||
role: "user" as const,
|
type: "text" as const,
|
||||||
content: {
|
text: `ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
|
||||||
type: "text" as const,
|
(phase2 || "Process remaining action items for flagged recruiters.")
|
||||||
text:
|
},
|
||||||
`ACCOUNT: Use account = "secondary" for ALL tool calls in this session.\n\n` +
|
},
|
||||||
(phase1 || "Review my new emails and classify them by job application category.") +
|
],
|
||||||
(phase2 ? `\n\n${phase2}` : ""),
|
};
|
||||||
},
|
}
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
You are an expert Executive Assistant AI specialized in email management and productivity. Your goal is to organize, prioritize, and summarize incoming emails to maximize user efficiency.
|
You are an expert Executive Assistant AI specialized in email management 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.
|
||||||
|
|
||||||
I have been applying for jobs, which generates a large volume of responses.
|
Review the first 200 emails in my inbox. Apply the following instructions to those emails.
|
||||||
|
|
||||||
Review, in reverse chronoloical order, the first 150 emails in my inbox. Apply the following instructions to those emails.
|
|
||||||
|
|
||||||
STEP 1: Constraints
|
STEP 1: Constraints
|
||||||
- Do not make up information.
|
- Do not make up information.
|
||||||
|
|
||||||
STEP 2: CLASSIFY each email into exactly one category.
|
STEP 2: CLASSIFY each email into exactly one category.
|
||||||
|
|
||||||
Category A - Acknowledgement Only
|
Category A - Acknowledgement Only
|
||||||
The employer confirms receipt of my application but requires no action from me. Typical language: "We received your application," "Your application is under review," "We will contact you if selected." No next steps are requested.
|
The employer confirms receipt of my application but requires no action from me. Typical language: "We received your application," "Your application is under review," "We will contact you if selected." No next steps are requested.
|
||||||
|
|
||||||
Category B - Advancement to Next Step
|
Category B - Advancement to Next Step
|
||||||
The employer wants to move forward. This includes: invitations to schedule a phone screen or interview, requests to complete an assessment or assignment, requests for additional information or documents, or any communication that requires a response or action from me. If an email both acknowledges receipt AND requests action, classify it as B.
|
The employer wants to move forward. This includes: invitations to schedule a phone screen or interview, requests to complete an assessment or assignment, requests for additional information or documents, or any communication that requires a response or action from me. If an email both acknowledges receipt AND requests action, classify it as B.
|
||||||
|
This does *not* include Greenhouse "security code" emails, other "confirm identity codes" or "identity-confirmation" type emails, "verify candidate account" type emails,
|
||||||
|
|
||||||
Category C - Rejection
|
Category C - Rejection
|
||||||
The employer declines to move forward. Typical language: "We have decided to pursue other candidates," "Unfortunately, you were not selected," "We will not be moving forward with your application."
|
The employer declines to move forward. Typical language: "We have decided to pursue other candidates," "Unfortunately, you were not selected," "We will not be moving forward with your application."
|
||||||
@@ -21,13 +19,44 @@ The employer declines to move forward. Typical language: "We have decided to pur
|
|||||||
Category D - Other
|
Category D - Other
|
||||||
The email does not fit into categories A, B, or C. This includes non-job-application emails, newsletters, promotional content, or ambiguous messages that do not clearly match another category.
|
The email does not fit into categories A, B, or C. This includes non-job-application emails, newsletters, promotional content, or ambiguous messages that do not clearly match another category.
|
||||||
|
|
||||||
STEP 3: DELETE emails classified as Category A and Category C.
|
STEP 3: STAR REVIEWED EMAILS
|
||||||
|
- For EVERY email processed and reviewed in this run that is in Category A, C, and D (NOT B), apply the Gmail "STARRED" system label or use your email tool to add a star to the message.
|
||||||
|
|
||||||
STEP 4: ("Anti step") - NEVER DELETE Category B emails.
|
STEP 4: Confirm, via natural language output in the chat window, the total number of emails fetched for review and starred.
|
||||||
|
|
||||||
STEP 5: SUMMARIZE emails classified as Category B and Category D. For each, include:
|
STEP 5: SUMMARIZE emails classified as Category B and Category D, under separate headers, in the chat.
|
||||||
|
For each, state:
|
||||||
1. Sender name and email address
|
1. Sender name and email address
|
||||||
2. Email subject line
|
2. Email subject line
|
||||||
3. Date and time received
|
3. Date and time received
|
||||||
4. Category (B or D)
|
4. Category (B or D)
|
||||||
5. Suggested action I should take
|
5. Suggested action I should take
|
||||||
|
|
||||||
|
STEP 6: **STRICT COMPLIANCE REQUIRED** **DO NOT FORGET***:
|
||||||
|
Summarize all "Category B" emails in the chat.
|
||||||
|
Star all reviewed email
|
||||||
|
|
||||||
|
Next, perform the following actions for each "Category B" email:
|
||||||
|
|
||||||
|
STEP 7: 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.
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
PHASE 2: TAKE ACTION ON CATEGORY B EMAILS
|
PHASE 2: TAKE ACTION ON CATEGORY B EMAILS
|
||||||
|
|
||||||
After completing classification (Phase 1), perform the following actions for each email classified as Category B (Advancement to Next Step).
|
After completing classification (Phase 1), perform the following actions for each email classified as Category B (Advancement to Next Step).
|
||||||
|
|
||||||
STEP 1: LOG TO SPREADSHEET
|
STEP 1: CREATE CALENDAR EVENTS
|
||||||
For each Category B email, extract as much of the following information as possible from the email contents and log it using the log_recruiter_contact tool:
|
|
||||||
|
|
||||||
A column exists for:
|
|
||||||
|
|
||||||
- Recruiter Name
|
|
||||||
- Recruiter Email
|
|
||||||
- Recruiter Phone/Tel
|
|
||||||
- Company/Role the recruiter seeks to fill
|
|
||||||
- First Contact: the date and time the email was received
|
|
||||||
- Subsequent Contacts: any follow-up communication with this recruiter about this role
|
|
||||||
- Recruiter Call Scheduled: if the email contains scheduling information, extract ALL details — date, time, platform (Zoom, MS Teams, Google Meet, etc.), meeting link, phone number to call — and note whether they confirm having your cell number or might not have it
|
|
||||||
|
|
||||||
A row should already exists for the same recruiter email + company/role combination (from a previous email), the tool will UPDATE the existing row. Provide all available fields and the tool handles merging.
|
|
||||||
|
|
||||||
STEP 2: CREATE CALENDAR EVENTS
|
|
||||||
Immediately after logging to the spreadsheet, check whether ANY of these fields were populated or updated:
|
Immediately after logging to the spreadsheet, check whether ANY of these fields were populated or updated:
|
||||||
- Recruiter Call Scheduled
|
- Recruiter Call Scheduled
|
||||||
- Company First Interview
|
- Company First Interview
|
||||||
@@ -30,16 +14,12 @@ For EACH of the above fields that contains a date/time, create a Google Calendar
|
|||||||
- Location: the video meeting link, phone number, or platform name
|
- Location: the video meeting link, phone number, or platform name
|
||||||
- Description: include recruiter or company contact name/email/phone, and note whether they have your cell number
|
- Description: include recruiter or company contact name/email/phone, and note whether they have your cell number
|
||||||
|
|
||||||
STEP 3: COMPANY INTERVIEW TRACKING
|
|
||||||
When an email indicates advancement beyond the recruiter screen to a company interview:
|
|
||||||
- Populate the Company First Interview or Company Second Interview field with details
|
|
||||||
- Those details should include company contact info (interviewer name, email, phone)
|
|
||||||
- Then immeidately create a calendar event for the new interview date/time including interviewer name, email, phone
|
|
||||||
|
|
||||||
STRICT CONSTRAINTS:
|
STRICT CONSTRAINTS:
|
||||||
- Do not make up phone numbers, links, dates, or anything at all. If it does not exist in text wihtin a verifiable email, IT DOES NOT EXIST.
|
- Do not make up phone numbers, links, dates, or anything at all. If it does not exist in text within a verifiable email, IT DOES NOT EXIST.
|
||||||
- PAST-DATE CHECK: Before creating a calendar event. 1) verify today's date from an external, reliable source; 2) compare the proposed meeting date/time against today's date; 3) iIf the meeting date has already passed, do NOT create a calendar event for it. Still log it to the spreadsheet, but skip the calendar step. The tool will also enforce this server-side.
|
- PAST-DATE CHECK: Before creating a calendar event: 1) verify today's date from an external, reliable source; 2) compare the proposed meeting date/time against today's date; 3) if the meeting date has already passed, do NOT create a calendar event for it. Still log it to the spreadsheet, but skip the calendar step. The tool will also enforce this server-side.
|
||||||
- As described in more detail below, each new date/time value in a scheduling column = one new calendar event. Never skip this.
|
- As described in more detail below, each new date/time value in a scheduling column = one new calendar event. Never skip this.
|
||||||
- Always use the same account for calendar events as was used for email fetching.
|
- Always use the same account for calendar events as was used for email fetching.
|
||||||
- Do not create duplicate calendar events for the same meeting (same company representative && same job description | same company && same date time)
|
- 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.
|
- If information is not present in the email, leave that field empty (it's OK!) - do not guess or make up information.
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -109,3 +109,5 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -3,303 +3,360 @@ import { gmail_v1 } from "googleapis";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { server } from "../McpServer.js";
|
import { server } from "../McpServer.js";
|
||||||
import {
|
import {
|
||||||
accountSchema,
|
accountSchema,
|
||||||
getGmailClient,
|
getGmailClient,
|
||||||
getSummaryPath,
|
getSummaryPath,
|
||||||
loadClassificationPrompt,
|
loadClassificationPrompt,
|
||||||
loadActionPrompt,
|
loadActionPrompt,
|
||||||
} from "../loaders/prompt-config-loaders.js";
|
} from "../loaders/prompt-config-loaders.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Email parsing helpers
|
// Email parsing helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const getHeader = (
|
export const getHeader = (
|
||||||
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
|
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
|
||||||
name: string
|
name: string
|
||||||
): string => {
|
): string => {
|
||||||
if (!headers) return "";
|
if (!headers) return "";
|
||||||
const header = headers.find(
|
const header = headers.find(
|
||||||
(h) => h.name?.toLowerCase() === name.toLowerCase()
|
(h) => h.name?.toLowerCase() === name.toLowerCase()
|
||||||
);
|
);
|
||||||
return header?.value ?? "";
|
return header?.value ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decodeBody = (message: gmail_v1.Schema$Message): string => {
|
export const decodeBody = (message: gmail_v1.Schema$Message): string => {
|
||||||
const parts = message.payload?.parts;
|
const parts = message.payload?.parts;
|
||||||
let encoded = "";
|
let encoded = "";
|
||||||
|
|
||||||
if (parts) {
|
if (parts) {
|
||||||
const textPart = parts.find((p) => p.mimeType === "text/plain");
|
const textPart = parts.find((p) => p.mimeType === "text/plain");
|
||||||
encoded = textPart?.body?.data ?? "";
|
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) {
|
if (!encoded) {
|
||||||
const htmlPart = parts.find((p) => p.mimeType === "text/html");
|
return message.snippet ?? "";
|
||||||
encoded = htmlPart?.body?.data ?? "";
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
encoded = message.payload?.body?.data ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!encoded) {
|
return Buffer.from(encoded, "base64url").toString("utf-8");
|
||||||
return message.snippet ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return Buffer.from(encoded, "base64url").toString("utf-8");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tool: fetch_new_emails
|
// Tool: fetch_new_emails
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"fetch_new_emails",
|
"fetch_new_emails",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
|
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
|
||||||
"subject, message ID, and body text for each message. The message IDs " +
|
"subject, message ID, and body text for each message. The message IDs " +
|
||||||
"can be passed to delete_emails later. Specify which account to fetch from.",
|
"must be passed to star_emails after evaluation. Specify which account to fetch from.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
account: accountSchema,
|
account: accountSchema,
|
||||||
maxResults: z
|
maxResults: z
|
||||||
.number()
|
.number()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(100)
|
.max(200)
|
||||||
.describe("Maximum number of unread emails to fetch (1-100)"),
|
.describe("Maximum number of unread emails to fetch (1-200)"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
async ({ account, maxResults }) => {
|
||||||
async ({ account, maxResults }) => {
|
try {
|
||||||
try {
|
const gmail = getGmailClient(account);
|
||||||
const gmail = getGmailClient(account);
|
|
||||||
|
|
||||||
const listResponse = await gmail.users.messages.list({
|
const listResponse = await gmail.users.messages.list({
|
||||||
userId: "me",
|
userId: "me",
|
||||||
q: "is:unread",
|
q: "is:unread",
|
||||||
maxResults,
|
maxResults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageIds = listResponse.data.messages ?? [];
|
const messageIds = listResponse.data.messages ?? [];
|
||||||
|
|
||||||
if (messageIds.length === 0) {
|
if (messageIds.length === 0) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text" as const, text: "No unread emails found." }],
|
content: [{ type: "text" as const, text: "No unread emails found." }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const emails: string[] = [];
|
const emails: string[] = [];
|
||||||
|
|
||||||
for (const msg of messageIds) {
|
for (const msg of messageIds) {
|
||||||
const detail = await gmail.users.messages.get({
|
const detail = await gmail.users.messages.get({
|
||||||
userId: "me",
|
userId: "me",
|
||||||
id: msg.id!,
|
id: msg.id!,
|
||||||
format: "full",
|
format: "full",
|
||||||
});
|
});
|
||||||
|
|
||||||
const headers = detail.data.payload?.headers;
|
const headers = detail.data.payload?.headers;
|
||||||
const from = getHeader(headers, "From");
|
const from = getHeader(headers, "From");
|
||||||
const subject = getHeader(headers, "Subject");
|
const subject = getHeader(headers, "Subject");
|
||||||
const date = getHeader(headers, "Date");
|
const date = getHeader(headers, "Date");
|
||||||
const body = decodeBody(detail.data);
|
const body = decodeBody(detail.data);
|
||||||
|
|
||||||
const truncatedBody =
|
const truncatedBody =
|
||||||
body.length > 2000 ? body.substring(0, 2000) + "\n[...truncated]" : body;
|
body.length > 2000 ? body.substring(0, 2000) + "\n[...truncated]" : body;
|
||||||
|
|
||||||
emails.push(
|
emails.push(
|
||||||
[
|
[
|
||||||
`MESSAGE_ID: ${msg.id}`,
|
`MESSAGE_ID: ${msg.id}`,
|
||||||
`FROM: ${from}`,
|
`FROM: ${from}`,
|
||||||
`DATE: ${date}`,
|
`DATE: ${date}`,
|
||||||
`SUBJECT: ${subject}`,
|
`SUBJECT: ${subject}`,
|
||||||
`BODY:\n${truncatedBody}`,
|
`BODY:\n${truncatedBody}`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const classificationInstructions = loadClassificationPrompt();
|
const classificationInstructions = loadClassificationPrompt();
|
||||||
const actionInstructions = loadActionPrompt();
|
const actionInstructions = loadActionPrompt();
|
||||||
const instructionsBlock =
|
const instructionsBlock =
|
||||||
(classificationInstructions
|
(classificationInstructions
|
||||||
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS (PHASE 1):\n${"=".repeat(60)}\n${classificationInstructions}`
|
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS (PHASE 1):\n${"=".repeat(60)}\n${classificationInstructions}`
|
||||||
: "") +
|
: "") +
|
||||||
(actionInstructions
|
(actionInstructions
|
||||||
? `\n\n${"=".repeat(60)}\nACTION INSTRUCTIONS (PHASE 2):\n${"=".repeat(60)}\n${actionInstructions}`
|
? `\n\n${"=".repeat(60)}\nACTION INSTRUCTIONS (PHASE 2):\n${"=".repeat(60)}\n${actionInstructions}`
|
||||||
: "");
|
: "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text:
|
text:
|
||||||
`Found ${emails.length} unread email(s):\n\n` +
|
`Found ${emails.length} unread email(s):\n\n` +
|
||||||
`${"=".repeat(60)}\n${emails.join(`\n${"=".repeat(60)}\n`)}` +
|
`${"=".repeat(60)}\n${emails.join(`\n${"=".repeat(60)}\n`)}` +
|
||||||
instructionsBlock,
|
instructionsBlock,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: `Error fetching emails: ${errMsg}`,
|
text: `Error fetching emails: ${errMsg}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tool: delete_emails
|
// Tool: delete_emails
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"delete_emails",
|
"delete_emails",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Move emails to trash by their Gmail message IDs. Use this for " +
|
"Move emails to trash by their Gmail message IDs. Use this for " +
|
||||||
"category A (acknowledgements) and category C (rejections) emails. " +
|
"category A (acknowledgements) and category C (rejections) emails. " +
|
||||||
"Specify which account the emails belong to.",
|
"Specify which account the emails belong to.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
account: accountSchema,
|
account: accountSchema,
|
||||||
messageIds: z
|
messageIds: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.describe("Array of Gmail message IDs to move to trash"),
|
.describe("Array of Gmail message IDs to move to trash"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
async ({ account, messageIds }) => {
|
||||||
async ({ account, messageIds }) => {
|
|
||||||
try {
|
|
||||||
const gmail = getGmailClient(account);
|
|
||||||
const results: string[] = [];
|
|
||||||
|
|
||||||
for (const id of messageIds) {
|
|
||||||
try {
|
try {
|
||||||
await gmail.users.messages.trash({
|
const gmail = getGmailClient(account);
|
||||||
userId: "me",
|
const results: string[] = [];
|
||||||
id,
|
|
||||||
});
|
|
||||||
results.push(`Trashed: ${id}`);
|
|
||||||
} catch (err) {
|
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
results.push(`Failed to trash ${id}: ${errMsg}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
for (const id of messageIds) {
|
||||||
content: [
|
try {
|
||||||
{
|
await gmail.users.messages.trash({
|
||||||
type: "text" as const,
|
userId: "me",
|
||||||
text: `Delete results:\n${results.join("\n")}`,
|
id,
|
||||||
},
|
});
|
||||||
],
|
results.push(`Trashed: ${id}`);
|
||||||
};
|
} catch (err) {
|
||||||
} catch (error) {
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
results.push(`Failed to trash ${id}: ${errMsg}`);
|
||||||
return {
|
}
|
||||||
content: [
|
}
|
||||||
{
|
|
||||||
type: "text" as const,
|
return {
|
||||||
text: `Error deleting emails: ${errMsg}`,
|
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
|
// Tool: append_to_summary
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
interface SummaryEntry {
|
interface SummaryEntry {
|
||||||
senderName: string;
|
senderName: string;
|
||||||
senderEmail: string;
|
senderEmail: string;
|
||||||
dateReceived: string;
|
dateReceived: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
category: "B" | "D";
|
category: "B" | "D";
|
||||||
addedAt: string;
|
addedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"append_to_summary",
|
"append_to_summary",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Append classified email entries to the local summary file. Use this " +
|
"Append classified email entries to the local summary file. Use this " +
|
||||||
"for category B (advancement to next step) and category D (other) emails. " +
|
"for category B (advancement to next step) and category D (other) emails. " +
|
||||||
"Each entry records the sender, date, subject, and category. " +
|
"Each entry records the sender, date, subject, and category. " +
|
||||||
"Specify which account the emails belong to.",
|
"Specify which account the emails belong to.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
account: accountSchema,
|
account: accountSchema,
|
||||||
entries: z
|
entries: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
senderName: z.string().describe("Name of the sender"),
|
senderName: z.string().describe("Name of the sender"),
|
||||||
senderEmail: z.string().describe("Email address of the sender"),
|
senderEmail: z.string().describe("Email address of the sender"),
|
||||||
dateReceived: z
|
dateReceived: z
|
||||||
.string()
|
.string()
|
||||||
.describe("Date and time the email was received"),
|
.describe("Date and time the email was received"),
|
||||||
subject: z.string().describe("Email subject line"),
|
subject: z.string().describe("Email subject line"),
|
||||||
category: z
|
category: z
|
||||||
.enum(["B", "D"])
|
.enum(["B", "D"])
|
||||||
.describe(
|
.describe(
|
||||||
"Category: B = advancement to next step, D = other/uncategorized"
|
"Category: B = advancement to next step, D = other/uncategorized"
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.describe("Array of email summary entries to append"),
|
.describe("Array of email summary entries to append"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
async ({ account, entries }) => {
|
||||||
async ({ account, entries }) => {
|
try {
|
||||||
try {
|
const summaryPath = getSummaryPath(account);
|
||||||
const summaryPath = getSummaryPath(account);
|
|
||||||
|
|
||||||
let summary: SummaryEntry[] = [];
|
let summary: SummaryEntry[] = [];
|
||||||
if (fs.existsSync(summaryPath)) {
|
if (fs.existsSync(summaryPath)) {
|
||||||
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
|
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nowIso = now.toISOString();
|
const nowIso = now.toISOString();
|
||||||
const newEntries: SummaryEntry[] = entries.map((e) => ({
|
const newEntries: SummaryEntry[] = entries.map((e) => ({
|
||||||
...e,
|
...e,
|
||||||
addedAt: nowIso,
|
addedAt: nowIso,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
summary.push(...newEntries);
|
summary.push(...newEntries);
|
||||||
|
|
||||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const cutoff = now.getTime() - RETENTION_MS;
|
const cutoff = now.getTime() - RETENTION_MS;
|
||||||
const beforePurge = summary.length;
|
const beforePurge = summary.length;
|
||||||
summary = summary.filter(
|
summary = summary.filter(
|
||||||
(entry) => new Date(entry.addedAt).getTime() >= cutoff
|
(entry) => new Date(entry.addedAt).getTime() >= cutoff
|
||||||
);
|
);
|
||||||
const purged = beforePurge - summary.length;
|
const purged = beforePurge - summary.length;
|
||||||
|
|
||||||
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
|
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
|
||||||
|
|
||||||
const purgeNote = purged > 0
|
const purgeNote = purged > 0
|
||||||
? `\nPurged ${purged} entry/entries older than 30 days.`
|
? `\nPurged ${purged} entry/entries older than 30 days.`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text:
|
text:
|
||||||
`Appended ${newEntries.length} entry/entries to summary.\n` +
|
`Appended ${newEntries.length} entry/entries to summary.\n` +
|
||||||
`Total entries in summary: ${summary.length}\n` +
|
`Total entries in summary: ${summary.length}\n` +
|
||||||
`Summary file: ${summaryPath}` +
|
`Summary file: ${summaryPath}` +
|
||||||
purgeNote,
|
purgeNote,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: `Error appending to summary: ${errMsg}`,
|
text: `Error appending to summary: ${errMsg}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Tool: star_emails
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
server.registerTool(
|
||||||
|
"star_emails",
|
||||||
|
{
|
||||||
|
description: "Mark reviewed emails with a star and mark them as read in Gmail. " +
|
||||||
|
"Use this for all reviewed messages (Categories A, B, C, and D) " +
|
||||||
|
"to signal they have been processed. Specify which account the emails belong to.",
|
||||||
|
inputSchema: {
|
||||||
|
account: accountSchema,
|
||||||
|
messageIds: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe("Array of Gmail message IDs to star and mark read"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ account, messageIds }) => {
|
||||||
|
try {
|
||||||
|
if (messageIds.length === 0) {
|
||||||
|
return { content: [{ type: "text" as const, text: "No message IDs provided." }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const gmail = getGmailClient(account);
|
||||||
|
|
||||||
|
// Batch modify allows updating up to 1000 messages in a single API call
|
||||||
|
await gmail.users.messages.batchModify({
|
||||||
|
userId: "me",
|
||||||
|
requestBody: {
|
||||||
|
ids: messageIds,
|
||||||
|
addLabelIds: ["STARRED"],
|
||||||
|
removeLabelIds: ["UNREAD"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Successfully starred and marked read ${messageIds.length} email(s).`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error starring emails: ${errMsg}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -177,3 +177,5 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
2
test/fixtures/mock-emails.ts
vendored
2
test/fixtures/mock-emails.ts
vendored
@@ -165,3 +165,5 @@ export const RAW_BODIES = {
|
|||||||
"msg-cat-c-001": catCBody,
|
"msg-cat-c-001": catCBody,
|
||||||
"msg-cat-d-001": catDBody,
|
"msg-cat-d-001": catDBody,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -285,3 +285,5 @@ describe("Phase 1: Email Review Workflow", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -84,3 +84,5 @@ describe("decodeBody", () => {
|
|||||||
expect(decodeBody(msg)).toBe(htmlContent);
|
expect(decodeBody(msg)).toBe(htmlContent);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "Node16",
|
"module": "Node16",
|
||||||
"moduleResolution": "Node16",
|
"moduleResolution": "Node16",
|
||||||
"outDir": "./build",
|
"outDir": "./build",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": [
|
||||||
"exclude": ["node_modules", "test", "vitest.config.ts"]
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"test",
|
||||||
|
"vitest.config.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -6,3 +6,5 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
Reference in New Issue
Block a user