Add multi-account support and CORS/logging middleware

- Add garfield, sales, leads, founder accounts to IMAP and SMTP configs
- Refactor fetcherpay config into shared helper functions
- Add CORS middleware with wildcard origin
- Add request logging middleware and MCP session lifecycle logs
- Include package-lock.json and add @types/cors dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
garfieldheron
2026-04-14 08:45:45 -04:00
parent 9ecb02785c
commit 166f5d55a6
6 changed files with 2670 additions and 66 deletions

2510
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,15 +11,17 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"@types/cors": "^2.8.19",
"cors": "^2.8.6",
"dotenv": "^16.0.0",
"express": "^4.18.0",
"imapflow": "^1.0.0",
"nodemailer": "^6.9.0",
"dotenv": "^16.0.0"
"nodemailer": "^6.9.0"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/nodemailer": "^6.4.0",
"@types/node": "^20.0.0",
"@types/nodemailer": "^6.4.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}

View File

@@ -1,29 +1,58 @@
import { ImapFlow } from 'imapflow';
export type Account = 'yahoo' | 'fetcherpay';
export type Account = 'yahoo' | 'fetcherpay' | 'garfield' | 'sales' | 'leads' | 'founder';
const FETCHERPAY_IMAP_HOST = process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com';
const FETCHERPAY_IMAP_PORT = parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993');
function fetcherpayImapConfig(user: string, pass: string) {
return {
host: FETCHERPAY_IMAP_HOST,
port: FETCHERPAY_IMAP_PORT,
secure: true,
auth: { user, pass },
tls: { rejectUnauthorized: false },
};
}
function getConfig(account: Account = 'yahoo') {
if (account === 'fetcherpay') {
return {
host: process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com',
port: parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'),
secure: true,
auth: {
user: process.env['FETCHERPAY_EMAIL'] as string,
pass: process.env['FETCHERPAY_PASSWORD'] as string,
},
tls: { rejectUnauthorized: false }, // self-signed cert on self-hosted server
};
switch (account) {
case 'fetcherpay':
return fetcherpayImapConfig(
process.env['FETCHERPAY_EMAIL'] as string,
process.env['FETCHERPAY_PASSWORD'] as string,
);
case 'garfield':
return fetcherpayImapConfig(
process.env['GARFIELD_EMAIL'] as string,
process.env['GARFIELD_PASSWORD'] as string,
);
case 'sales':
return fetcherpayImapConfig(
process.env['SALES_EMAIL'] as string,
process.env['SALES_PASSWORD'] as string,
);
case 'leads':
return fetcherpayImapConfig(
process.env['LEADS_EMAIL'] as string,
process.env['LEADS_PASSWORD'] as string,
);
case 'founder':
return fetcherpayImapConfig(
process.env['FOUNDER_EMAIL'] as string,
process.env['FOUNDER_PASSWORD'] as string,
);
default:
return {
host: 'imap.mail.yahoo.com',
port: 993,
secure: true,
auth: {
user: process.env['YAHOO_EMAIL'] as string,
pass: process.env['YAHOO_APP_PASSWORD'] as string,
},
};
}
return {
host: 'imap.mail.yahoo.com',
port: 993,
secure: true,
auth: {
user: process.env['YAHOO_EMAIL'] as string,
pass: process.env['YAHOO_APP_PASSWORD'] as string,
},
};
}
async function withClient<T>(account: Account, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
@@ -159,9 +188,15 @@ export async function readMessage(uid: number, account: Account = 'yahoo'): Prom
}
export async function getProfile(account: Account = 'yahoo'): Promise<{ email: string; name: string; account: string }> {
const email = account === 'fetcherpay'
? (process.env['FETCHERPAY_EMAIL'] ?? '')
: (process.env['YAHOO_EMAIL'] ?? '');
const emailMap: Record<Account, string> = {
yahoo: process.env['YAHOO_EMAIL'] ?? '',
fetcherpay: process.env['FETCHERPAY_EMAIL'] ?? '',
garfield: process.env['GARFIELD_EMAIL'] ?? '',
sales: process.env['SALES_EMAIL'] ?? '',
leads: process.env['LEADS_EMAIL'] ?? '',
founder: process.env['FOUNDER_EMAIL'] ?? '',
};
const email = emailMap[account] ?? '';
return { email, name: email.split('@')[0], account };
}

View File

@@ -1,5 +1,6 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -11,8 +12,24 @@ import {
import { tools, handleToolCall } from './tools.js';
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept'],
credentials: true
}));
app.use(express.json());
// Request logging middleware
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.path} - ${req.headers['user-agent'] || 'no-ua'}`);
if (req.body && Object.keys(req.body).length > 0) {
console.log(` Body: ${JSON.stringify(req.body).substring(0, 500)}`);
}
next();
});
function createMcpServer() {
const server = new Server(
{ name: 'hermes', version: '1.0.0' },
@@ -33,11 +50,13 @@ const httpTransports = new Map<string, StreamableHTTPServerTransport>();
app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
console.log(`[mcp] POST sessionId=${sessionId ?? 'none'}, isInit=${isInitializeRequest(req.body)}`);
let transport: StreamableHTTPServerTransport;
if (sessionId && httpTransports.has(sessionId)) {
// Known active session — reuse it
console.log(`[mcp] Reusing existing session ${sessionId}`);
transport = httpTransports.get(sessionId)!;
} else if (isInitializeRequest(req.body)) {
// Initialize request: create a new session.
@@ -46,15 +65,23 @@ app.post('/mcp', async (req, res) => {
if (sessionId) {
console.warn(`[mcp] Stale session ${sessionId} re-initializing — pod may have restarted`);
}
console.log(`[mcp] Creating new session`);
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => { httpTransports.set(id, transport); },
onsessioninitialized: (id) => {
console.log(`[mcp] Session initialized: ${id}`);
httpTransports.set(id, transport);
},
});
transport.onclose = () => {
if (transport.sessionId) httpTransports.delete(transport.sessionId);
if (transport.sessionId) {
console.log(`[mcp] Session closed: ${transport.sessionId}`);
httpTransports.delete(transport.sessionId);
}
};
const server = createMcpServer();
await server.connect(transport);
console.log(`[mcp] Server connected to transport`);
} else {
// Unknown session + non-initialize request: session expired (e.g. pod restarted).
// Return 404 so MCP clients know to re-initialize rather than keep retrying.
@@ -63,7 +90,13 @@ app.post('/mcp', async (req, res) => {
return;
}
await transport.handleRequest(req, res, req.body);
try {
await transport.handleRequest(req, res, req.body);
console.log(`[mcp] Request handled successfully`);
} catch (err) {
console.error(`[mcp] Error handling request:`, err);
throw err;
}
});
app.get('/mcp', async (req, res) => {

View File

@@ -1,34 +1,54 @@
import nodemailer from 'nodemailer';
import type { Account } from './imap.js';
function getSmtpTransport(account: Account = 'yahoo') {
if (account === 'fetcherpay') {
return nodemailer.createTransport({
host: process.env['FETCHERPAY_SMTP_HOST'] ?? 'mail.fetcherpay.com',
port: parseInt(process.env['FETCHERPAY_SMTP_PORT'] ?? '30587'),
secure: false, // STARTTLS
auth: {
user: process.env['FETCHERPAY_EMAIL']!,
pass: process.env['FETCHERPAY_PASSWORD']!,
},
tls: { rejectUnauthorized: false }, // self-signed cert
});
}
const FETCHERPAY_SMTP_HOST = process.env['FETCHERPAY_SMTP_HOST'] ?? 'mail.fetcherpay.com';
const FETCHERPAY_SMTP_PORT = parseInt(process.env['FETCHERPAY_SMTP_PORT'] ?? '30587');
function fetcherpaySmtpTransport(user: string, pass: string) {
return nodemailer.createTransport({
host: 'smtp.mail.yahoo.com',
port: 587,
secure: false,
auth: {
user: process.env['YAHOO_EMAIL']!,
pass: process.env['YAHOO_APP_PASSWORD']!,
},
host: FETCHERPAY_SMTP_HOST,
port: FETCHERPAY_SMTP_PORT,
secure: false, // STARTTLS
auth: { user, pass },
tls: { rejectUnauthorized: false },
});
}
function getSmtpTransport(account: Account = 'yahoo') {
switch (account) {
case 'fetcherpay':
return fetcherpaySmtpTransport(process.env['FETCHERPAY_EMAIL']!, process.env['FETCHERPAY_PASSWORD']!);
case 'garfield':
return fetcherpaySmtpTransport(process.env['GARFIELD_EMAIL']!, process.env['GARFIELD_PASSWORD']!);
case 'sales':
return fetcherpaySmtpTransport(process.env['SALES_EMAIL']!, process.env['SALES_PASSWORD']!);
case 'leads':
return fetcherpaySmtpTransport(process.env['LEADS_EMAIL']!, process.env['LEADS_PASSWORD']!);
case 'founder':
return fetcherpaySmtpTransport(process.env['FOUNDER_EMAIL']!, process.env['FOUNDER_PASSWORD']!);
default:
return nodemailer.createTransport({
host: 'smtp.mail.yahoo.com',
port: 587,
secure: false,
auth: {
user: process.env['YAHOO_EMAIL']!,
pass: process.env['YAHOO_APP_PASSWORD']!,
},
});
}
}
function getSenderEmail(account: Account = 'yahoo'): string {
return account === 'fetcherpay'
? process.env['FETCHERPAY_EMAIL']!
: process.env['YAHOO_EMAIL']!;
const emailMap: Record<Account, string> = {
yahoo: process.env['YAHOO_EMAIL'] ?? '',
fetcherpay: process.env['FETCHERPAY_EMAIL'] ?? '',
garfield: process.env['GARFIELD_EMAIL'] ?? '',
sales: process.env['SALES_EMAIL'] ?? '',
leads: process.env['LEADS_EMAIL'] ?? '',
founder: process.env['FOUNDER_EMAIL'] ?? '',
};
return emailMap[account] ?? '';
}
export async function sendEmail(
@@ -55,17 +75,21 @@ export async function createDraft(
): Promise<string> {
const { ImapFlow } = await import('imapflow');
const imapConfig = account === 'fetcherpay'
? {
host: process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com',
port: parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'),
secure: true,
auth: {
user: process.env['FETCHERPAY_EMAIL']!,
pass: process.env['FETCHERPAY_PASSWORD']!,
},
tls: { rejectUnauthorized: false },
}
const fetcherpayImapBase = {
host: process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com',
port: parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'),
secure: true,
tls: { rejectUnauthorized: false },
};
const fetcherpayImapAccounts: Partial<Record<Account, { user: string; pass: string }>> = {
fetcherpay: { user: process.env['FETCHERPAY_EMAIL']!, pass: process.env['FETCHERPAY_PASSWORD']! },
garfield: { user: process.env['GARFIELD_EMAIL']!, pass: process.env['GARFIELD_PASSWORD']! },
sales: { user: process.env['SALES_EMAIL']!, pass: process.env['SALES_PASSWORD']! },
leads: { user: process.env['LEADS_EMAIL']!, pass: process.env['LEADS_PASSWORD']! },
founder: { user: process.env['FOUNDER_EMAIL']!, pass: process.env['FOUNDER_PASSWORD']! },
};
const imapConfig = fetcherpayImapAccounts[account]
? { ...fetcherpayImapBase, auth: fetcherpayImapAccounts[account]! }
: {
host: 'imap.mail.yahoo.com',
port: 993,

View File

@@ -5,8 +5,8 @@ import { sendEmail, createDraft } from './smtp.js';
const ACCOUNT_PARAM = {
account: {
type: 'string',
enum: ['yahoo', 'fetcherpay'],
description: 'Which mailbox to use: "yahoo" (gheron01@yahoo.com) or "fetcherpay" (garfield.heron@fetcherpay.com). Defaults to "yahoo".',
enum: ['yahoo', 'fetcherpay', 'garfield', 'sales', 'leads', 'founder'],
description: 'Which mailbox to use: "yahoo" (gheron01@yahoo.com), "fetcherpay" (garfield.heron@fetcherpay.com), "garfield" (garfield@fetcherpay.com), "sales" (sales@fetcherpay.com), "leads" (leads@fetcherpay.com), or "founder" (founder@fetcherpay.com). Defaults to "yahoo".',
},
};