This commit is contained in:
KS Jannette
2026-02-17 13:22:40 -05:00
parent fa80df8652
commit cde346f42c
3 changed files with 249 additions and 34 deletions

View File

@@ -1,16 +1,16 @@
# Declawed (A Mail Bot)
# Declawed: A Configurable, Promptable AI Mail Assisty Kitty
A local Model Context Protocol (MCP) server connecing LLM platform APIs (ie Claude Desktop), or locally hosted models to your mail account.
Ssimple.
No Surprises, unlike the blue-plate-special-crustacean of the day.
Automates review, classification, and action -- response, deletion, archiving -- for inbox items.
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.
Ssssimple. Siamsese, if you please.... Eats the blue-plate-crustacean for breakfast.
Infinitely mod-able. Dead simple. Privacy centric.
---
# Scope
## Current implementaiton contemplates: Claude Desktop + Local Custom Model Context Protocol Server + Goog-MX'ed SMTP for your Registered, DNS'd XXX.YYY domain
(highly config'able- more to follow)
## Current implementation contemplates: Claude Desktop + Custom, Local Model Context Protocol Server + Commercial SMTP Server (MX'configd properly) + your DNS-configd XXX.YYY
---
@@ -268,3 +268,5 @@ assistant/
├── tsconfig.json # TypeScript compiler config
└── README.md # This file
```

View File

@@ -12,10 +12,54 @@ import path from "path";
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 SUMMARY_PATH = path.join(PROJECT_ROOT, "summary.json");
const ACCOUNTS_PATH = path.join(PROJECT_ROOT, "accounts.json");
const PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt");
// ---------------------------------------------------------------------------
// 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 accountSchema = z
.enum(VALID_ACCOUNTS)
.describe(
"Which email account to use: \"work\" (sj@sjdev.co) or \"secondary\" (ken.jannette@gmail.com)"
);
// ---------------------------------------------------------------------------
// Load classification prompt
// ---------------------------------------------------------------------------
@@ -28,17 +72,19 @@ function loadClassificationPrompt(): string {
}
// ---------------------------------------------------------------------------
// Gmail auth helper
// Gmail auth helper — parameterized by account key
// ---------------------------------------------------------------------------
function getGmailClient(): gmail_v1.Gmail {
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(TOKEN_PATH)) {
if (!fs.existsSync(tokenPath)) {
throw new Error(
`Missing token.json at ${TOKEN_PATH}. Run "npm run auth" first.`
`Missing token file at ${tokenPath} for account "${account}". Run "npm run auth" first.`
);
}
@@ -52,17 +98,17 @@ function getGmailClient(): gmail_v1.Gmail {
redirect_uris[0]
);
const token = JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8"));
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(TOKEN_PATH, "utf-8"));
const current = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
fs.writeFileSync(
TOKEN_PATH,
tokenPath,
JSON.stringify({ ...current, ...newTokens }, null, 2)
);
console.error("Token refreshed and saved.");
console.error(`Token refreshed and saved for account "${account}".`);
});
return google.gmail({ version: "v1", auth: oAuth2Client });
@@ -117,13 +163,13 @@ const server = new McpServer({
});
// ---------------------------------------------------------------------------
// Prompt: review_emails
// Prompt: review_emails (work account)
// ---------------------------------------------------------------------------
server.registerPrompt(
"review_emails",
{
description:
"Review inbox, classify job application emails (A/B/C/D), delete A+C, summarize B+D.",
"Review WORK inbox (sj@sjdev.co): classify job application emails (A/B/C/D), delete A+C, summarize B+D.",
},
() => {
const instructions = loadClassificationPrompt();
@@ -133,8 +179,38 @@ server.registerPrompt(
role: "user" as const,
content: {
type: "text" as const,
text: instructions ||
"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` +
(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 (ken.jannette@gmail.com): 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."),
},
},
],
@@ -149,10 +225,11 @@ server.registerTool(
"fetch_new_emails",
{
description:
"Fetch unread emails from the 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 " +
"can be passed to delete_emails later.",
"can be passed to delete_emails later. Specify which account to fetch from.",
inputSchema: {
account: accountSchema,
maxResults: z
.number()
.min(1)
@@ -160,9 +237,9 @@ server.registerTool(
.describe("Maximum number of unread emails to fetch (1-100)"),
},
},
async ({ maxResults }) => {
async ({ account, maxResults }) => {
try {
const gmail = getGmailClient();
const gmail = getGmailClient(account);
const listResponse = await gmail.users.messages.list({
userId: "me",
@@ -246,16 +323,18 @@ server.registerTool(
{
description:
"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.",
inputSchema: {
account: accountSchema,
messageIds: z
.array(z.string())
.describe("Array of Gmail message IDs to move to trash"),
},
},
async ({ messageIds }) => {
async ({ account, messageIds }) => {
try {
const gmail = getGmailClient();
const gmail = getGmailClient(account);
const results: string[] = [];
for (const id of messageIds) {
@@ -311,8 +390,10 @@ server.registerTool(
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.",
"Each entry records the sender, date, subject, and category. " +
"Specify which account the emails belong to.",
inputSchema: {
account: accountSchema,
entries: z
.array(
z.object({
@@ -332,12 +413,14 @@ server.registerTool(
.describe("Array of email summary entries to append"),
},
},
async ({ entries }) => {
async ({ account, entries }) => {
try {
const summaryPath = getSummaryPath(account);
// Load existing summary or start fresh
let summary: SummaryEntry[] = [];
if (fs.existsSync(SUMMARY_PATH)) {
summary = JSON.parse(fs.readFileSync(SUMMARY_PATH, "utf-8"));
if (fs.existsSync(summaryPath)) {
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
}
const now = new Date().toISOString();
@@ -347,7 +430,7 @@ server.registerTool(
}));
summary.push(...newEntries);
fs.writeFileSync(SUMMARY_PATH, JSON.stringify(summary, null, 2));
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
return {
content: [
@@ -356,7 +439,7 @@ server.registerTool(
text:
`Appended ${newEntries.length} entry/entries to summary.\n` +
`Total entries in summary: ${summary.length}\n` +
`Summary file: ${SUMMARY_PATH}`,
`Summary file: ${summaryPath}`,
},
],
};

130
summary-secondary.json Normal file
View File

@@ -0,0 +1,130 @@
[
{
"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"
}
]