From e66eea75dc2f96de7f0a1374b6010bc9c0d9c4d1 Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Wed, 18 Feb 2026 05:48:50 -0500 Subject: [PATCH] more --- classify-emails.txt | 30 --- src/auth.ts | 52 +++- src/index.ts | 357 ++++++++++++++++++++++++-- src/prompts/classify-emails.txt | 7 +- src/prompts/take-action-on-emails.txt | 45 ++++ 5 files changed, 423 insertions(+), 68 deletions(-) delete mode 100644 classify-emails.txt create mode 100644 src/prompts/take-action-on-emails.txt diff --git a/classify-emails.txt b/classify-emails.txt deleted file mode 100644 index 22bb67d..0000000 --- a/classify-emails.txt +++ /dev/null @@ -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 - diff --git a/src/auth.ts b/src/auth.ts index bec639d..ba69ea5 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -6,18 +6,43 @@ 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 ACCOUNTS_PATH = path.join(PROJECT_ROOT, "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(PROJECT_ROOT, acct.tokenFile); +} async function authorize(): Promise { + 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" ); @@ -34,16 +59,18 @@ async function authorize(): Promise { 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 +96,6 @@ async function authorize(): Promise { ); }); - // 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 +107,8 @@ async function authorize(): Promise { 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."); } diff --git a/src/index.ts b/src/index.ts index fe2dc36..1fc3650 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; -import { google, gmail_v1 } from "googleapis"; +import { google, gmail_v1, sheets_v4, calendar_v3 } from "googleapis"; +import { OAuth2Client } from "google-auth-library"; import fs from "fs"; import path from "path"; @@ -13,7 +14,10 @@ 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"); +const CLASSIFY_PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt"); +const ACTION_PROMPT_PATH = path.join( + PROJECT_ROOT, "src", "prompts", "take-action-on-emails.txt" +); // --------------------------------------------------------------------------- // Account configuration @@ -21,6 +25,8 @@ const PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt"); interface AccountConfig { label: string; tokenFile: string; + spreadsheetId?: string; + calendarId?: string; } interface AccountsMap { @@ -63,20 +69,28 @@ const accountSchema = z .describe(`Which email account to use: ${accountDescription}`); // --------------------------------------------------------------------------- -// Load classification prompt +// Prompt loaders // --------------------------------------------------------------------------- -function loadClassificationPrompt(): string { - if (!fs.existsSync(PROMPT_PATH)) { - console.error(`Warning: ${PROMPT_PATH} not found. Classification instructions will be missing.`); +function loadPromptFile(filePath: string, label: string): string { + if (!fs.existsSync(filePath)) { + console.error(`Warning: ${filePath} not found. ${label} will be missing.`); return ""; } - return fs.readFileSync(PROMPT_PATH, "utf-8"); + return fs.readFileSync(filePath, "utf-8"); +} + +function loadClassificationPrompt(): string { + return loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions"); +} + +function loadActionPrompt(): string { + return loadPromptFile(ACTION_PROMPT_PATH, "Action instructions"); } // --------------------------------------------------------------------------- -// Gmail auth helper — parameterized by account key +// Auth helpers — generic OAuth2 client, then service-specific factories // --------------------------------------------------------------------------- -function getGmailClient(account: string): gmail_v1.Gmail { +function getOAuth2Client(account: string): OAuth2Client { const tokenPath = getTokenPath(account); if (!fs.existsSync(CREDENTIALS_PATH)) { @@ -103,7 +117,6 @@ function getGmailClient(account: string): gmail_v1.Gmail { 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( @@ -113,7 +126,33 @@ function getGmailClient(account: string): gmail_v1.Gmail { console.error(`Token refreshed and saved for account "${account}".`); }); - return google.gmail({ version: "v1", auth: oAuth2Client }); + return oAuth2Client; +} + +function getGmailClient(account: string): gmail_v1.Gmail { + return google.gmail({ version: "v1", auth: getOAuth2Client(account) }); +} + +function getSheetsClient(account: string): sheets_v4.Sheets { + return google.sheets({ version: "v4", auth: getOAuth2Client(account) }); +} + +function getCalendarClient(account: string): calendar_v3.Calendar { + return google.calendar({ version: "v3", auth: getOAuth2Client(account) }); +} + +function getSpreadsheetId(account: string): string { + const acct = accounts[account]; + if (!acct?.spreadsheetId || acct.spreadsheetId === "PASTE_YOUR_SHEET_ID_HERE") { + throw new Error( + `No spreadsheetId configured for account "${account}" in accounts.json.` + ); + } + return acct.spreadsheetId; +} + +function getCalendarId(account: string): string { + return accounts[account]?.calendarId ?? "primary"; } // --------------------------------------------------------------------------- @@ -171,10 +210,11 @@ 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.`, + `Review WORK inbox (${accounts.work?.label ?? "work"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`, }, () => { - const instructions = loadClassificationPrompt(); + const phase1 = loadClassificationPrompt(); + const phase2 = loadActionPrompt(); return { messages: [ { @@ -183,8 +223,8 @@ server.registerPrompt( 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."), + (phase1 || "Review my new emails and classify them by job application category.") + + (phase2 ? `\n\n${phase2}` : ""), }, }, ], @@ -199,10 +239,11 @@ 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.`, + `Review SECONDARY inbox (${accounts.secondary?.label ?? "secondary"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`, }, () => { - const instructions = loadClassificationPrompt(); + const phase1 = loadClassificationPrompt(); + const phase2 = loadActionPrompt(); return { messages: [ { @@ -211,8 +252,8 @@ server.registerPrompt( 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."), + (phase1 || "Review my new emails and classify them by job application category.") + + (phase2 ? `\n\n${phase2}` : ""), }, }, ], @@ -288,9 +329,14 @@ server.registerTool( } const classificationInstructions = loadClassificationPrompt(); - const instructionsBlock = classificationInstructions - ? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS:\n${"=".repeat(60)}\n${classificationInstructions}` - : ""; + 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: [ @@ -475,6 +521,273 @@ server.registerTool( } ); +// --------------------------------------------------------------------------- +// Sheet column layout for "Recruiter Communication Log and Call Schedule" +// --------------------------------------------------------------------------- +const SHEET_COLUMNS = [ + "Recruiter Name", // A + "Recruiter Email", // B + "Recruiter Tel", // C + "Company/Role", // D + "First Contact", // E + "Subsequent Contact(s)", // F + "Recruiter Call Scheduled", // G + "Company Contact Info", // H + "Company First Interview", // I + "Company Second Interview", // J +]; + +const SHEET_RANGE = "Sheet1"; + +// --------------------------------------------------------------------------- +// Tool: log_recruiter_contact +// --------------------------------------------------------------------------- +server.registerTool( + "log_recruiter_contact", + { + description: + "Log or update recruiter contact info in the tracking spreadsheet. " + + "If a row already exists for the same recruiterEmail + companyRole, it " + + "updates the existing row (merging non-empty fields). Otherwise appends " + + "a new row. Use for Category B emails after classification.", + inputSchema: { + account: accountSchema, + recruiterName: z.string().describe("Recruiter's full name"), + recruiterEmail: z.string().describe("Recruiter's email address"), + recruiterTel: z + .string() + .optional() + .describe("Recruiter's phone number, if mentioned in the email"), + companyRole: z + .string() + .describe("Company name and role the recruiter seeks to fill"), + firstContact: z + .string() + .describe("Date/time of first contact (from the email's Date header)"), + subsequentContacts: z + .string() + .optional() + .describe("Any follow-up contact context mentioned in the email"), + recruiterCallScheduled: z + .string() + .optional() + .describe( + "Scheduled call details: date, time, platform (Zoom/Teams), " + + "meeting link or phone, and whether they have your cell number" + ), + companyContactInfo: z + .string() + .optional() + .describe("Company interviewer name/email/tel if advancing to company interview"), + companyFirstInterview: z + .string() + .optional() + .describe("Company first interview: date, time, platform, link/phone, cell number note"), + companySecondInterview: z + .string() + .optional() + .describe("Company second interview: date, time, platform, link/phone, cell number note"), + }, + }, + async ({ + account, + recruiterName, + recruiterEmail, + recruiterTel, + companyRole, + firstContact, + subsequentContacts, + recruiterCallScheduled, + companyContactInfo, + companyFirstInterview, + companySecondInterview, + }) => { + try { + const sheets = getSheetsClient(account); + const spreadsheetId = getSpreadsheetId(account); + + const incomingRow = [ + recruiterName, + recruiterEmail, + recruiterTel ?? "", + companyRole, + firstContact, + subsequentContacts ?? "", + recruiterCallScheduled ?? "", + companyContactInfo ?? "", + companyFirstInterview ?? "", + companySecondInterview ?? "", + ]; + + // Read existing data to check for a matching row + const existing = await sheets.spreadsheets.values.get({ + spreadsheetId, + range: SHEET_RANGE, + }); + const rows = existing.data.values ?? []; + + // Find row matching recruiterEmail (col B) + companyRole (col D) + const emailNorm = recruiterEmail.toLowerCase().trim(); + const roleNorm = companyRole.toLowerCase().trim(); + let matchIdx = -1; + for (let i = 0; i < rows.length; i++) { + const rowEmail = (rows[i][1] ?? "").toString().toLowerCase().trim(); + const rowRole = (rows[i][3] ?? "").toString().toLowerCase().trim(); + if (rowEmail === emailNorm && rowRole === roleNorm) { + matchIdx = i; + break; + } + } + + if (matchIdx >= 0) { + // Merge: keep existing values where incoming is empty + const existingRow = rows[matchIdx]; + const merged = incomingRow.map((val, col) => { + if (col === 5 && val && existingRow[col]) { + // Subsequent Contacts: append rather than overwrite + return `${existingRow[col]}; ${val}`; + } + return val || existingRow[col] || ""; + }); + + const rowNum = matchIdx + 1; // 1-indexed + await sheets.spreadsheets.values.update({ + spreadsheetId, + range: `${SHEET_RANGE}!A${rowNum}:J${rowNum}`, + valueInputOption: "USER_ENTERED", + requestBody: { values: [merged] }, + }); + + return { + content: [ + { + type: "text" as const, + text: `Updated existing row ${rowNum} for ${recruiterName} / ${companyRole}.`, + }, + ], + }; + } + + // No match — append new row + await sheets.spreadsheets.values.append({ + spreadsheetId, + range: SHEET_RANGE, + valueInputOption: "USER_ENTERED", + requestBody: { values: [incomingRow] }, + }); + + return { + content: [ + { + type: "text" as const, + text: `Appended new row for ${recruiterName} / ${companyRole}.`, + }, + ], + }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text" as const, + text: `Error logging recruiter contact: ${errMsg}`, + }, + ], + }; + } + } +); + +// --------------------------------------------------------------------------- +// Tool: create_calendar_event +// --------------------------------------------------------------------------- +server.registerTool( + "create_calendar_event", + { + description: + "Create a Google Calendar event. Use immediately after logging a " + + "recruiter call, company first interview, or company second interview " + + "to the spreadsheet. One calendar event per scheduling column populated.", + inputSchema: { + account: accountSchema, + title: z + .string() + .describe( + "Event title, e.g. '[Recruiter Call] Acme Corp - Sr Engineer' " + + "or '[Interview] Acme Corp - Sr Engineer'" + ), + startDateTime: z + .string() + .describe("Event start in ISO 8601 format, e.g. 2026-02-20T14:00:00-06:00"), + durationMinutes: z + .number() + .min(5) + .max(480) + .optional() + .describe("Duration in minutes (default 60)"), + description: z + .string() + .optional() + .describe( + "Event description: recruiter/company contact info, notes, " + + "whether they have your cell number, etc." + ), + location: z + .string() + .optional() + .describe("Video meeting link (Zoom/Teams URL) or phone number"), + }, + }, + async ({ account, title, startDateTime, durationMinutes, description, location }) => { + try { + const calendar = getCalendarClient(account); + const calendarId = getCalendarId(account); + + const start = new Date(startDateTime); + const end = new Date(start.getTime() + (durationMinutes ?? 60) * 60_000); + + const event = await calendar.events.insert({ + calendarId, + requestBody: { + summary: title, + start: { + dateTime: start.toISOString(), + }, + end: { + dateTime: end.toISOString(), + }, + description: description ?? "", + location: location ?? "", + }, + }); + + return { + content: [ + { + type: "text" as const, + text: + `Created calendar event: "${title}"\n` + + `Start: ${start.toISOString()}\n` + + `End: ${end.toISOString()}\n` + + `Event ID: ${event.data.id}\n` + + `Link: ${event.data.htmlLink}`, + }, + ], + }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text" as const, + text: `Error creating calendar event: ${errMsg}`, + }, + ], + }; + } + } +); + // --------------------------------------------------------------------------- // Start server // --------------------------------------------------------------------------- diff --git a/src/prompts/classify-emails.txt b/src/prompts/classify-emails.txt index 22bb67d..55c9468 100644 --- a/src/prompts/classify-emails.txt +++ b/src/prompts/classify-emails.txt @@ -21,10 +21,11 @@ The email does not fit into categories A, B, or C. This includes non-job-applica 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 4: ("Anti step") - NEVER DELETE Category B emails. + +STEP 5: SUMMARIZE emails classified as Category B and Category D. For each, include: 1. Sender name and email address 2. Email subject line 3. Date and time received 4. Category (B or D) -5. Suggested action I should take - +5. Suggested action I should take \ No newline at end of file diff --git a/src/prompts/take-action-on-emails.txt b/src/prompts/take-action-on-emails.txt new file mode 100644 index 0000000..4874c1a --- /dev/null +++ b/src/prompts/take-action-on-emails.txt @@ -0,0 +1,45 @@ +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: LOG TO SPREADSHEET +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: +- Recruiter Call Scheduled +- Company First Interview +- Company Second Interview + +For EACH of the aboce fields that contains a date/time, create a Google Calendar event using the create_calendar_event tool: +- Title format: "[ 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 + +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: +- 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. +- 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. +- If information is not present in the email, leave that field empty do ot guess. +