MCP IMAP server with core email tools
Implements complete IMAP server using FastMCP protocol with 5 core tools: - list_folders: Browse mailbox structure - search_emails: Query with filters (sender, subject, date) - get_email: Fetch full email content with metadata - send_email: SMTP sending with HTML support - health_check: Connection validation Architecture: - FastMCP for MCP protocol implementation - aioimaplib for async IMAP connections - IMAPSession class for connection pooling - REST API bridge (api_server.py) for HTTP access Tech: Python 3.11 + FastMCP + aioimaplib + FastAPI + aiosmtplib
This commit is contained in:
parent
3c5a9a35e8
commit
8232a58600
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
backend/venv/
|
||||||
|
backend/.deps_installed
|
||||||
|
|
||||||
|
# Node.js / Next.js
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/.next/
|
||||||
|
frontend/out/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Process IDs
|
||||||
|
.backend.pid
|
||||||
|
.frontend.pid
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
next-env.d.ts
|
||||||
22
backend/.env.example
Normal file
22
backend/.env.example
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# MCP Server Configuration
|
||||||
|
MCP_PORT=8000
|
||||||
|
MCP_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Security Settings
|
||||||
|
RATE_LIMIT_PER_MINUTE=60
|
||||||
|
MAX_EMAIL_FETCH_SIZE=100
|
||||||
|
SESSION_TIMEOUT_MINUTES=30
|
||||||
|
|
||||||
|
# Redis Configuration (for production)
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# CORS Settings (for frontend)
|
||||||
|
CORS_ORIGINS=http://localhost:3000,https://maxtheweb.ai
|
||||||
|
|
||||||
|
# Auth Settings (for magic links)
|
||||||
|
RESEND_API_KEY=your_resend_api_key
|
||||||
|
MAGIC_LINK_EXPIRY_MINUTES=15
|
||||||
33
backend/requirements.txt
Normal file
33
backend/requirements.txt
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Core MCP dependencies
|
||||||
|
fastmcp>=0.5.0
|
||||||
|
|
||||||
|
# IMAP email handling
|
||||||
|
imapclient>=3.0.0
|
||||||
|
email-validator>=2.1.0
|
||||||
|
|
||||||
|
# SMTP support
|
||||||
|
aiosmtplib>=3.0.0
|
||||||
|
|
||||||
|
# Security and authentication
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
cryptography>=41.0.0
|
||||||
|
|
||||||
|
# Async support and performance
|
||||||
|
aiofiles>=23.2.1
|
||||||
|
aioimaplib>=1.0.1
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
slowapi>=0.1.9
|
||||||
|
|
||||||
|
# Web server
|
||||||
|
fastapi>=0.100.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
pydantic>=2.5.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.23.0
|
||||||
|
pytest-cov>=4.1.0
|
||||||
401
backend/src/api_server.py
Normal file
401
backend/src/api_server.py
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
"""
|
||||||
|
REST API server for MCP IMAP Agent
|
||||||
|
Provides HTTP endpoints for the Next.js frontend to communicate with MCP tools
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, BackgroundTasks
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
|
# Import MCP server components
|
||||||
|
from mcp_server import (
|
||||||
|
get_session,
|
||||||
|
IMAPConfig,
|
||||||
|
list_folders as mcp_list_folders,
|
||||||
|
search_emails as mcp_search_emails,
|
||||||
|
get_email as mcp_get_email,
|
||||||
|
send_email as mcp_send_email,
|
||||||
|
health_check as mcp_health_check,
|
||||||
|
Context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create rate limiter
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
# Lifespan manager for FastAPI
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup
|
||||||
|
logger.info("Starting API server...")
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
logger.info("Shutting down API server...")
|
||||||
|
|
||||||
|
# Create FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title="MCP IMAP Agent API",
|
||||||
|
version="1.0.0",
|
||||||
|
description="REST API for MCP IMAP Agent",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add rate limiter error handler
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
|
# Configure CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:3000").split(","),
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Request models
|
||||||
|
class ConnectionConfig(BaseModel):
|
||||||
|
"""Email connection configuration"""
|
||||||
|
host: str
|
||||||
|
port: int = 993
|
||||||
|
username: EmailStr
|
||||||
|
password: str
|
||||||
|
smtpHost: Optional[str] = None
|
||||||
|
smtpPort: Optional[int] = 587
|
||||||
|
|
||||||
|
class ValidateConnectionRequest(BaseModel):
|
||||||
|
"""Connection validation request"""
|
||||||
|
host: str
|
||||||
|
port: int = 993
|
||||||
|
username: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
"""Chat request with LLM"""
|
||||||
|
message: str
|
||||||
|
connection: ConnectionConfig
|
||||||
|
history: Optional[list] = []
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
"""Email search request"""
|
||||||
|
connection: ConnectionConfig
|
||||||
|
folder: str = "INBOX"
|
||||||
|
sender: Optional[str] = None
|
||||||
|
subject: Optional[str] = None
|
||||||
|
body: Optional[str] = None
|
||||||
|
since: Optional[str] = None
|
||||||
|
limit: int = 20
|
||||||
|
|
||||||
|
class GetEmailRequest(BaseModel):
|
||||||
|
"""Get single email request"""
|
||||||
|
connection: ConnectionConfig
|
||||||
|
folder: str = "INBOX"
|
||||||
|
email_id: str
|
||||||
|
|
||||||
|
class SendEmailRequest(BaseModel):
|
||||||
|
"""Send email request"""
|
||||||
|
connection: ConnectionConfig
|
||||||
|
to: EmailStr
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
cc: Optional[str] = None
|
||||||
|
is_html: bool = False
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Root endpoint"""
|
||||||
|
return {"message": "MCP IMAP Agent API", "version": "1.0.0"}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def health(request: Request):
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/api/validate-connection")
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def validate_connection(request: Request, conn: ValidateConnectionRequest):
|
||||||
|
"""Validate IMAP connection"""
|
||||||
|
try:
|
||||||
|
# Test connection by listing folders
|
||||||
|
result = await mcp_list_folders(
|
||||||
|
None, # Context not needed for direct calls
|
||||||
|
username=conn.username,
|
||||||
|
password=conn.password,
|
||||||
|
host=conn.host
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Connection successful",
|
||||||
|
"folders": result.get("folders", [])
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error", "Connection failed"))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Connection validation error: {e}")
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/chat")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def chat(request: Request, chat_req: ChatRequest):
|
||||||
|
"""
|
||||||
|
Handle chat messages and execute MCP tools
|
||||||
|
Returns streaming response with results
|
||||||
|
"""
|
||||||
|
async def generate():
|
||||||
|
try:
|
||||||
|
# Parse the message to determine action
|
||||||
|
message = chat_req.message.lower()
|
||||||
|
conn = chat_req.connection
|
||||||
|
# Context not needed for REST API calls
|
||||||
|
|
||||||
|
# Simple intent detection (in production, use LLM)
|
||||||
|
if "list folder" in message or "show folder" in message:
|
||||||
|
result = await mcp_list_folders(
|
||||||
|
ctx,
|
||||||
|
username=conn.username,
|
||||||
|
password=conn.password,
|
||||||
|
host=conn.host
|
||||||
|
)
|
||||||
|
yield json.dumps({"type": "folders", "data": result}) + "\n"
|
||||||
|
|
||||||
|
elif "search" in message or "find" in message or "show" in message:
|
||||||
|
# Extract search parameters from message
|
||||||
|
search_params = {
|
||||||
|
"folder": "INBOX",
|
||||||
|
"limit": 20
|
||||||
|
}
|
||||||
|
|
||||||
|
if "from" in message:
|
||||||
|
# Simple extraction (in production, use NLP)
|
||||||
|
parts = message.split("from")
|
||||||
|
if len(parts) > 1:
|
||||||
|
sender = parts[1].strip().split()[0]
|
||||||
|
search_params["sender"] = sender
|
||||||
|
|
||||||
|
if "subject" in message:
|
||||||
|
parts = message.split("subject")
|
||||||
|
if len(parts) > 1:
|
||||||
|
subject = parts[1].strip().split()[0]
|
||||||
|
search_params["subject"] = subject
|
||||||
|
|
||||||
|
if "last week" in message:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
week_ago = datetime.now() - timedelta(days=7)
|
||||||
|
search_params["since"] = week_ago.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
result = await mcp_search_emails(
|
||||||
|
ctx,
|
||||||
|
username=conn.username,
|
||||||
|
password=conn.password,
|
||||||
|
host=conn.host,
|
||||||
|
**search_params
|
||||||
|
)
|
||||||
|
|
||||||
|
yield json.dumps({"type": "search", "data": result}) + "\n"
|
||||||
|
|
||||||
|
elif "send" in message or "draft" in message or "reply" in message:
|
||||||
|
# For demo, just return a template
|
||||||
|
yield json.dumps({
|
||||||
|
"type": "draft",
|
||||||
|
"data": {
|
||||||
|
"success": True,
|
||||||
|
"message": "Ready to compose email. Please provide recipient, subject, and body.",
|
||||||
|
"template": {
|
||||||
|
"to": "",
|
||||||
|
"subject": "",
|
||||||
|
"body": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) + "\n"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Default: search recent emails
|
||||||
|
result = await mcp_search_emails(
|
||||||
|
ctx,
|
||||||
|
username=conn.username,
|
||||||
|
password=conn.password,
|
||||||
|
host=conn.host,
|
||||||
|
folder="INBOX",
|
||||||
|
limit=10
|
||||||
|
)
|
||||||
|
|
||||||
|
yield json.dumps({
|
||||||
|
"type": "info",
|
||||||
|
"data": {
|
||||||
|
"message": f"Here are your recent emails. You can ask me to search, read, draft, or send emails.",
|
||||||
|
"emails": result.get("emails", [])
|
||||||
|
}
|
||||||
|
}) + "\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Chat error: {e}")
|
||||||
|
yield json.dumps({"type": "error", "data": {"error": str(e)}}) + "\n"
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
@app.post("/api/search")
|
||||||
|
@limiter.limit("20/minute")
|
||||||
|
async def search_emails(request: Request, search_req: SearchRequest):
|
||||||
|
"""Search emails with filters"""
|
||||||
|
try:
|
||||||
|
# Context not needed for REST API calls
|
||||||
|
conn = search_req.connection
|
||||||
|
|
||||||
|
result = await mcp_search_emails(
|
||||||
|
ctx,
|
||||||
|
username=conn.username,
|
||||||
|
password=conn.password,
|
||||||
|
host=conn.host,
|
||||||
|
folder=search_req.folder,
|
||||||
|
sender=search_req.sender,
|
||||||
|
subject=search_req.subject,
|
||||||
|
body=search_req.body,
|
||||||
|
since=search_req.since,
|
||||||
|
limit=search_req.limit
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Search error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/get-email")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def get_email(request: Request, email_req: GetEmailRequest):
|
||||||
|
"""Get full email content"""
|
||||||
|
try:
|
||||||
|
# Context not needed for REST API calls
|
||||||
|
conn = email_req.connection
|
||||||
|
|
||||||
|
result = await mcp_get_email(
|
||||||
|
ctx,
|
||||||
|
username=conn.username,
|
||||||
|
password=conn.password,
|
||||||
|
host=conn.host,
|
||||||
|
folder=email_req.folder,
|
||||||
|
email_id=email_req.email_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get email error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/send")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def send_email_endpoint(request: Request, send_req: SendEmailRequest):
|
||||||
|
"""Send an email"""
|
||||||
|
try:
|
||||||
|
# Context not needed for REST API calls
|
||||||
|
conn = send_req.connection
|
||||||
|
|
||||||
|
# Use SMTP host if provided, otherwise derive from IMAP host
|
||||||
|
smtp_host = conn.smtpHost
|
||||||
|
if not smtp_host:
|
||||||
|
if "gmail" in conn.host:
|
||||||
|
smtp_host = "smtp.gmail.com"
|
||||||
|
elif "outlook" in conn.host or "office365" in conn.host:
|
||||||
|
smtp_host = "smtp.office365.com"
|
||||||
|
else:
|
||||||
|
# Try replacing imap with smtp
|
||||||
|
smtp_host = conn.host.replace("imap.", "smtp.")
|
||||||
|
|
||||||
|
result = await mcp_send_email(
|
||||||
|
ctx,
|
||||||
|
username=conn.username,
|
||||||
|
password=conn.password,
|
||||||
|
smtp_host=smtp_host,
|
||||||
|
to=send_req.to,
|
||||||
|
subject=send_req.subject,
|
||||||
|
body=send_req.body,
|
||||||
|
cc=send_req.cc,
|
||||||
|
is_html=send_req.is_html
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Send email error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/folders")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def list_folders(request: Request, conn: ConnectionConfig):
|
||||||
|
"""List email folders"""
|
||||||
|
try:
|
||||||
|
# Context not needed for REST API calls
|
||||||
|
result = await mcp_list_folders(
|
||||||
|
ctx,
|
||||||
|
username=conn.username,
|
||||||
|
password=conn.password,
|
||||||
|
host=conn.host
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"List folders error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/demo-request")
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def demo_request(request: Request, data: dict):
|
||||||
|
"""
|
||||||
|
Handle demo requests - sends user inquiry to max@maxtheweb.ai
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_email = data.get("email")
|
||||||
|
user_message = data.get("message", "Demo request")
|
||||||
|
|
||||||
|
# TODO: Send email to max@maxtheweb.ai with user's demo request
|
||||||
|
# For now, just log it
|
||||||
|
logger.info(f"Demo request from {user_email}: {user_message}")
|
||||||
|
|
||||||
|
# In production, you would:
|
||||||
|
# 1. Send email to max@maxtheweb.ai with user details
|
||||||
|
# 2. Queue AI processing job
|
||||||
|
# 3. Send results back to user_email
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Demo request received"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Demo request error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
port = int(os.getenv("API_PORT", 8001))
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||||
456
backend/src/mcp_server.py
Normal file
456
backend/src/mcp_server.py
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
"""
|
||||||
|
MCP IMAP Agent Server - AI-powered email automation via MCP protocol
|
||||||
|
Built with FastMCP for production-ready performance
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from functools import lru_cache
|
||||||
|
import email
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
|
from fastmcp import FastMCP, Context
|
||||||
|
from pydantic import BaseModel, Field, EmailStr
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import aioimaplib
|
||||||
|
from email_validator import validate_email, EmailNotValidError
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Initialize FastMCP server
|
||||||
|
mcp = FastMCP("mcp-imap-agent")
|
||||||
|
|
||||||
|
# Rate limiter for security
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
# Configuration models
|
||||||
|
class IMAPConfig(BaseModel):
|
||||||
|
"""IMAP connection configuration"""
|
||||||
|
host: str = Field(..., description="IMAP server hostname")
|
||||||
|
port: int = Field(993, description="IMAP port (993 for SSL)")
|
||||||
|
username: EmailStr = Field(..., description="Email address")
|
||||||
|
password: str = Field(..., description="App-specific password")
|
||||||
|
use_ssl: bool = Field(True, description="Use SSL connection")
|
||||||
|
|
||||||
|
class EmailSearchParams(BaseModel):
|
||||||
|
"""Email search parameters"""
|
||||||
|
folder: str = Field("INBOX", description="Folder to search in")
|
||||||
|
sender: Optional[str] = Field(None, description="Filter by sender email")
|
||||||
|
subject: Optional[str] = Field(None, description="Filter by subject")
|
||||||
|
body: Optional[str] = Field(None, description="Search in email body")
|
||||||
|
since: Optional[str] = Field(None, description="Emails since date (YYYY-MM-DD)")
|
||||||
|
limit: int = Field(50, description="Maximum emails to return", ge=1, le=100)
|
||||||
|
|
||||||
|
class IMAPSession:
|
||||||
|
"""Manages IMAP connection with connection pooling"""
|
||||||
|
|
||||||
|
def __init__(self, config: IMAPConfig):
|
||||||
|
self.config = config
|
||||||
|
self.client: Optional[aioimaplib.IMAP4_SSL] = None
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Establish IMAP connection with retry logic"""
|
||||||
|
async with self._lock:
|
||||||
|
if self.client is None:
|
||||||
|
try:
|
||||||
|
self.client = aioimaplib.IMAP4_SSL(
|
||||||
|
host=self.config.host,
|
||||||
|
port=self.config.port
|
||||||
|
)
|
||||||
|
await self.client.wait_hello_from_server()
|
||||||
|
|
||||||
|
# Authenticate
|
||||||
|
response = await self.client.login(
|
||||||
|
self.config.username,
|
||||||
|
self.config.password
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.result != 'OK':
|
||||||
|
raise Exception(f"Login failed: {response}")
|
||||||
|
|
||||||
|
logger.info(f"Connected to {self.config.host} as {self.config.username}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Connection failed: {e}")
|
||||||
|
self.client = None
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Close IMAP connection gracefully"""
|
||||||
|
async with self._lock:
|
||||||
|
if self.client:
|
||||||
|
try:
|
||||||
|
await self.client.logout()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
async def ensure_connected(self):
|
||||||
|
"""Ensure connection is active"""
|
||||||
|
if self.client is None:
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
async def execute(self, command: str, *args):
|
||||||
|
"""Execute IMAP command with automatic reconnection"""
|
||||||
|
await self.ensure_connected()
|
||||||
|
try:
|
||||||
|
return await getattr(self.client, command)(*args)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Command failed, reconnecting: {e}")
|
||||||
|
await self.disconnect()
|
||||||
|
await self.connect()
|
||||||
|
return await getattr(self.client, command)(*args)
|
||||||
|
|
||||||
|
# Global session cache (in production, use Redis)
|
||||||
|
sessions: Dict[str, IMAPSession] = {}
|
||||||
|
|
||||||
|
@lru_cache(maxsize=128)
|
||||||
|
def get_session(username: str, password: str, host: str) -> IMAPSession:
|
||||||
|
"""Get or create IMAP session with caching"""
|
||||||
|
key = f"{username}@{host}"
|
||||||
|
if key not in sessions:
|
||||||
|
config = IMAPConfig(
|
||||||
|
host=host,
|
||||||
|
username=username,
|
||||||
|
password=password
|
||||||
|
)
|
||||||
|
sessions[key] = IMAPSession(config)
|
||||||
|
return sessions[key]
|
||||||
|
|
||||||
|
# MCP Tools Implementation
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_folders(
|
||||||
|
ctx: Context,
|
||||||
|
username: EmailStr = Field(..., description="Email address"),
|
||||||
|
password: str = Field(..., description="App-specific password"),
|
||||||
|
host: str = Field(..., description="IMAP server hostname")
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
List all email folders in the account.
|
||||||
|
Returns folder names and their attributes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session = get_session(username, password, host)
|
||||||
|
await session.ensure_connected()
|
||||||
|
|
||||||
|
# List folders
|
||||||
|
response = await session.execute('list')
|
||||||
|
|
||||||
|
if response.result != 'OK':
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Failed to list folders"
|
||||||
|
}
|
||||||
|
|
||||||
|
folders = []
|
||||||
|
for line in response.lines:
|
||||||
|
# Parse IMAP LIST response
|
||||||
|
if isinstance(line, bytes):
|
||||||
|
line = line.decode('utf-8')
|
||||||
|
|
||||||
|
# Extract folder name (simplified parsing)
|
||||||
|
parts = line.split('"')
|
||||||
|
if len(parts) >= 3:
|
||||||
|
folder_name = parts[-2]
|
||||||
|
folders.append({
|
||||||
|
"name": folder_name,
|
||||||
|
"path": folder_name
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"folders": folders,
|
||||||
|
"count": len(folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"list_folders error: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_emails(
|
||||||
|
ctx: Context,
|
||||||
|
username: EmailStr = Field(..., description="Email address"),
|
||||||
|
password: str = Field(..., description="App-specific password"),
|
||||||
|
host: str = Field(..., description="IMAP server hostname"),
|
||||||
|
folder: str = Field("INBOX", description="Folder to search"),
|
||||||
|
sender: Optional[str] = Field(None, description="Filter by sender"),
|
||||||
|
subject: Optional[str] = Field(None, description="Filter by subject"),
|
||||||
|
body: Optional[str] = Field(None, description="Search in body"),
|
||||||
|
since: Optional[str] = Field(None, description="Since date YYYY-MM-DD"),
|
||||||
|
limit: int = Field(20, description="Max results", ge=1, le=100)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Search emails with multiple filters.
|
||||||
|
Returns email metadata and snippets.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session = get_session(username, password, host)
|
||||||
|
await session.ensure_connected()
|
||||||
|
|
||||||
|
# Select folder
|
||||||
|
response = await session.execute('select', folder)
|
||||||
|
if response.result != 'OK':
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Cannot access folder: {folder}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build search criteria
|
||||||
|
criteria = ['ALL']
|
||||||
|
|
||||||
|
if sender:
|
||||||
|
criteria.append(f'FROM "{sender}"')
|
||||||
|
if subject:
|
||||||
|
criteria.append(f'SUBJECT "{subject}"')
|
||||||
|
if body:
|
||||||
|
criteria.append(f'BODY "{body}"')
|
||||||
|
if since:
|
||||||
|
date_obj = datetime.strptime(since, '%Y-%m-%d')
|
||||||
|
criteria.append(f'SINCE {date_obj.strftime("%d-%b-%Y")}')
|
||||||
|
|
||||||
|
search_string = ' '.join(criteria) if len(criteria) > 1 else criteria[0]
|
||||||
|
|
||||||
|
# Execute search
|
||||||
|
response = await session.execute('search', None, search_string)
|
||||||
|
|
||||||
|
if response.result != 'OK':
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Search failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse message IDs
|
||||||
|
message_ids = response.lines[0].split() if response.lines else []
|
||||||
|
message_ids = message_ids[-limit:] if len(message_ids) > limit else message_ids
|
||||||
|
|
||||||
|
emails = []
|
||||||
|
for msg_id in message_ids:
|
||||||
|
# Fetch email headers
|
||||||
|
response = await session.execute(
|
||||||
|
'fetch',
|
||||||
|
msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id),
|
||||||
|
'(BODY.PEEK[HEADER] FLAGS)'
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.result == 'OK' and response.lines:
|
||||||
|
# Parse email headers (simplified)
|
||||||
|
header_data = b''.join(
|
||||||
|
line if isinstance(line, bytes) else line.encode()
|
||||||
|
for line in response.lines if line
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = email.message_from_bytes(header_data)
|
||||||
|
|
||||||
|
emails.append({
|
||||||
|
"id": msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id),
|
||||||
|
"subject": msg.get('Subject', '(no subject)'),
|
||||||
|
"from": msg.get('From', ''),
|
||||||
|
"to": msg.get('To', ''),
|
||||||
|
"date": msg.get('Date', ''),
|
||||||
|
"folder": folder
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"emails": emails,
|
||||||
|
"count": len(emails),
|
||||||
|
"search_criteria": search_string
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"search_emails error: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_email(
|
||||||
|
ctx: Context,
|
||||||
|
username: EmailStr = Field(..., description="Email address"),
|
||||||
|
password: str = Field(..., description="App-specific password"),
|
||||||
|
host: str = Field(..., description="IMAP server hostname"),
|
||||||
|
email_id: str = Field(..., description="Email ID to retrieve"),
|
||||||
|
folder: str = Field("INBOX", description="Email folder")
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get full email content including body and attachments info.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session = get_session(username, password, host)
|
||||||
|
await session.ensure_connected()
|
||||||
|
|
||||||
|
# Select folder
|
||||||
|
await session.execute('select', folder)
|
||||||
|
|
||||||
|
# Fetch full email
|
||||||
|
response = await session.execute('fetch', email_id, '(RFC822)')
|
||||||
|
|
||||||
|
if response.result != 'OK':
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Failed to fetch email"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse email
|
||||||
|
raw_email = b''.join(
|
||||||
|
line if isinstance(line, bytes) else line.encode()
|
||||||
|
for line in response.lines if line
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = email.message_from_bytes(raw_email)
|
||||||
|
|
||||||
|
# Extract body
|
||||||
|
body_plain = ""
|
||||||
|
body_html = ""
|
||||||
|
attachments = []
|
||||||
|
|
||||||
|
for part in msg.walk():
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
content_disposition = str(part.get("Content-Disposition", ""))
|
||||||
|
|
||||||
|
if "attachment" in content_disposition:
|
||||||
|
filename = part.get_filename()
|
||||||
|
if filename:
|
||||||
|
attachments.append({
|
||||||
|
"filename": filename,
|
||||||
|
"content_type": content_type,
|
||||||
|
"size": len(part.get_payload())
|
||||||
|
})
|
||||||
|
elif content_type == "text/plain":
|
||||||
|
body_plain = part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
||||||
|
elif content_type == "text/html":
|
||||||
|
body_html = part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"email": {
|
||||||
|
"id": email_id,
|
||||||
|
"subject": msg.get('Subject', ''),
|
||||||
|
"from": msg.get('From', ''),
|
||||||
|
"to": msg.get('To', ''),
|
||||||
|
"cc": msg.get('Cc', ''),
|
||||||
|
"date": msg.get('Date', ''),
|
||||||
|
"body_plain": body_plain,
|
||||||
|
"body_html": body_html,
|
||||||
|
"attachments": attachments,
|
||||||
|
"folder": folder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"get_email error: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def send_email(
|
||||||
|
ctx: Context,
|
||||||
|
username: EmailStr = Field(..., description="Email address"),
|
||||||
|
password: str = Field(..., description="App-specific password"),
|
||||||
|
smtp_host: str = Field(..., description="SMTP server hostname"),
|
||||||
|
to: EmailStr = Field(..., description="Recipient email"),
|
||||||
|
subject: str = Field(..., description="Email subject"),
|
||||||
|
body: str = Field(..., description="Email body"),
|
||||||
|
cc: Optional[str] = Field(None, description="CC recipients"),
|
||||||
|
is_html: bool = Field(False, description="Send as HTML email")
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Send an email via SMTP.
|
||||||
|
Supports plain text and HTML formats.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import aiosmtplib
|
||||||
|
|
||||||
|
# Validate email
|
||||||
|
try:
|
||||||
|
validate_email(to)
|
||||||
|
except EmailNotValidError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Invalid recipient email: {e}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create message
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['From'] = username
|
||||||
|
msg['To'] = to
|
||||||
|
msg['Subject'] = subject
|
||||||
|
|
||||||
|
if cc:
|
||||||
|
msg['Cc'] = cc
|
||||||
|
|
||||||
|
# Add body
|
||||||
|
if is_html:
|
||||||
|
msg.attach(MIMEText(body, 'html'))
|
||||||
|
else:
|
||||||
|
msg.attach(MIMEText(body, 'plain'))
|
||||||
|
|
||||||
|
# Send via SMTP
|
||||||
|
async with aiosmtplib.SMTP(
|
||||||
|
hostname=smtp_host,
|
||||||
|
port=587,
|
||||||
|
start_tls=True
|
||||||
|
) as smtp:
|
||||||
|
await smtp.login(username, password)
|
||||||
|
await smtp.send_message(msg)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Email sent successfully",
|
||||||
|
"details": {
|
||||||
|
"to": to,
|
||||||
|
"subject": subject,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"send_email error: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
@mcp.tool()
|
||||||
|
async def health_check(ctx: Context) -> Dict[str, Any]:
|
||||||
|
"""Check server health and status"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"active_sessions": len(sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Run the MCP server
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
mcp.get_asgi_app(),
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=int(os.getenv("MCP_PORT", 8000)),
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user