diff --git a/src/server.py b/src/server.py index 0f6d589..f4fd233 100755 --- a/src/server.py +++ b/src/server.py @@ -1,12 +1,11 @@ #!/usr/bin/env python3 import asyncio -import json import logging import os import smtplib import ssl from contextlib import asynccontextmanager -from datetime import date, datetime +from datetime import date from email import policy from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -29,6 +28,7 @@ logger = logging.getLogger("poke-mail") # Auth — simple bearer token verification # --------------------------------------------------------------------------- + class ApiKeyAuth(TokenVerifier): """Validates incoming requests against a static API key (MCP_API_KEY).""" @@ -41,10 +41,12 @@ class ApiKeyAuth(TokenVerifier): return AccessToken(token=token, client_id="owner", scopes=["all"]) return None + # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- + def load_config() -> dict: path = os.environ.get("CONFIG_PATH", "config.yml") try: @@ -79,7 +81,14 @@ def parse_accounts(config: dict) -> list[dict]: } ] - required = ("imap_host", "imap_username", "imap_password", "smtp_host", "smtp_username", "smtp_password") + required = ( + "imap_host", + "imap_username", + "imap_password", + "smtp_host", + "smtp_username", + "smtp_password", + ) for i, acc in enumerate(accounts): acc.setdefault("id", f"account-{i}") acc.setdefault("imap_port", 993) @@ -87,7 +96,9 @@ def parse_accounts(config: dict) -> list[dict]: acc.setdefault("watch_folders", ["INBOX"]) for field in required: if field not in acc: - raise RuntimeError(f"Account '{acc['id']}' missing required field: {field}") + raise RuntimeError( + f"Account '{acc['id']}' missing required field: {field}" + ) return accounts @@ -97,13 +108,16 @@ def resolve_account(accounts: list[dict], account_id: Optional[str] = None) -> d for acc in accounts: if acc["id"] == account_id: return acc - raise ValueError(f"Unknown account_id: {account_id}. Available: {[a['id'] for a in accounts]}") + raise ValueError( + f"Unknown account_id: {account_id}. Available: {[a['id'] for a in accounts]}" + ) # --------------------------------------------------------------------------- # IMAP helpers # --------------------------------------------------------------------------- + def get_imap_client(account: dict) -> IMAPClient: port = account["imap_port"] use_ssl = port == 993 @@ -128,14 +142,16 @@ def parse_email_message(raw: bytes) -> dict: if "attachment" in cd: try: content = part.get_content() - size = len(content) if hasattr(content, '__len__') else 0 + size = len(content) if hasattr(content, "__len__") else 0 except Exception: size = 0 - attachments.append({ - "filename": part.get_filename() or "unnamed", - "content_type": ct, - "size": size, - }) + attachments.append( + { + "filename": part.get_filename() or "unnamed", + "content_type": ct, + "size": size, + } + ) elif ct == "text/plain" and not body_text: try: body_text = part.get_content() @@ -216,6 +232,7 @@ def detect_archive_folder(client: IMAPClient) -> str: # Poke webhook # --------------------------------------------------------------------------- + async def forward_to_poke(email_data: dict, webhook_url: str, api_key: str) -> bool: payload = { "from": email_data["from"], @@ -236,7 +253,11 @@ async def forward_to_poke(email_data: dict, webhook_url: str, api_key: str) -> b async with httpx.AsyncClient(timeout=30) as http: resp = await http.post(webhook_url, json=payload, headers=headers) resp.raise_for_status() - logger.info("Forwarded email '%s' to Poke (status %d)", email_data["subject"], resp.status_code) + logger.info( + "Forwarded email '%s' to Poke (status %d)", + email_data["subject"], + resp.status_code, + ) return True except Exception as e: logger.warning("Forward attempt %d failed: %s", attempt + 1, e) @@ -249,7 +270,14 @@ async def forward_to_poke(email_data: dict, webhook_url: str, api_key: str) -> b # IDLE watcher # --------------------------------------------------------------------------- -async def watch_folder(account: dict, folder: str, webhook_url: str, api_key: str, stop_event: asyncio.Event): + +async def watch_folder( + account: dict, + folder: str, + webhook_url: str, + api_key: str, + stop_event: asyncio.Event, +): backoff = 5 max_backoff = 60 @@ -261,11 +289,19 @@ async def watch_folder(account: dict, folder: str, webhook_url: str, api_key: st # Check IDLE support if not client.has_capability("IDLE"): - logger.warning("[%s/%s] Server does not support IDLE, falling back to polling", account["id"], folder) - await _poll_folder(client, account, folder, webhook_url, api_key, stop_event) + logger.warning( + "[%s/%s] Server does not support IDLE, falling back to polling", + account["id"], + folder, + ) + await _poll_folder( + client, account, folder, webhook_url, api_key, stop_event + ) return - logger.info("[%s/%s] Watching for new emails via IDLE", account["id"], folder) + logger.info( + "[%s/%s] Watching for new emails via IDLE", account["id"], folder + ) backoff = 5 while not stop_event.is_set(): @@ -304,7 +340,13 @@ async def watch_folder(account: dict, folder: str, webhook_url: str, api_key: st except asyncio.CancelledError: break except Exception as e: - logger.error("[%s/%s] Watcher error: %s (reconnecting in %ds)", account["id"], folder, e, backoff) + logger.error( + "[%s/%s] Watcher error: %s (reconnecting in %ds)", + account["id"], + folder, + e, + backoff, + ) await asyncio.sleep(backoff) backoff = min(backoff * 2, max_backoff) finally: @@ -315,7 +357,14 @@ async def watch_folder(account: dict, folder: str, webhook_url: str, api_key: st pass -async def _poll_folder(client: IMAPClient, account: dict, folder: str, webhook_url: str, api_key: str, stop_event: asyncio.Event): +async def _poll_folder( + client: IMAPClient, + account: dict, + folder: str, + webhook_url: str, + api_key: str, + stop_event: asyncio.Event, +): """Fallback polling for servers without IDLE support. Checks every 60 seconds.""" logger.info("[%s/%s] Polling for new emails every 60s", account["id"], folder) while not stop_event.is_set(): @@ -336,11 +385,17 @@ async def _poll_folder(client: IMAPClient, account: dict, folder: str, webhook_u await asyncio.sleep(60) -async def idle_watcher(accounts: list[dict], webhook_url: str, api_key: str, stop_event: asyncio.Event): +async def idle_watcher( + accounts: list[dict], webhook_url: str, api_key: str, stop_event: asyncio.Event +): tasks = [] for acc in accounts: for folder in acc.get("watch_folders", ["INBOX"]): - tasks.append(asyncio.create_task(watch_folder(acc, folder, webhook_url, api_key, stop_event))) + tasks.append( + asyncio.create_task( + watch_folder(acc, folder, webhook_url, api_key, stop_event) + ) + ) if tasks: await asyncio.gather(*tasks, return_exceptions=True) @@ -349,19 +404,30 @@ async def idle_watcher(accounts: list[dict], webhook_url: str, api_key: str, sto # Lifespan # --------------------------------------------------------------------------- + @asynccontextmanager async def lifespan(server: FastMCP): config = load_config() accounts = parse_accounts(config) - webhook_url = os.environ.get("POKE_WEBHOOK_URL", config.get("webhook_url", "https://poke.com/api/v1/inbound-sms/webhook")) + webhook_url = os.environ.get( + "POKE_WEBHOOK_URL", + config.get("webhook_url", "https://poke.com/api/v1/inbound-sms/webhook"), + ) api_key = os.environ.get("POKE_API_KEY", config.get("poke_api_key", "")) stop_event = asyncio.Event() - watcher_task = asyncio.create_task(idle_watcher(accounts, webhook_url, api_key, stop_event)) + watcher_task = asyncio.create_task( + idle_watcher(accounts, webhook_url, api_key, stop_event) + ) logger.info("poke-mail started with %d account(s)", len(accounts)) try: - yield {"accounts": accounts, "webhook_url": webhook_url, "api_key": api_key, "config": config} + yield { + "accounts": accounts, + "webhook_url": webhook_url, + "api_key": api_key, + "config": config, + } finally: stop_event.set() watcher_task.cancel() @@ -379,12 +445,16 @@ async def lifespan(server: FastMCP): mcp_api_key = os.environ.get("MCP_API_KEY", "") auth = ApiKeyAuth(mcp_api_key) if mcp_api_key else None if not mcp_api_key: - logger.warning("MCP_API_KEY not set — server is unauthenticated. Set MCP_API_KEY to secure it.") + logger.warning( + "MCP_API_KEY not set — server is unauthenticated. Set MCP_API_KEY to secure it." + ) mcp = FastMCP("poke-mail", lifespan=lifespan, auth=auth) -@mcp.tool(description="Search emails by criteria. Returns a list of matching emails with metadata.") +@mcp.tool( + description="Search emails by criteria. Returns a list of matching emails with metadata." +) async def search_emails( ctx: Context, folder: str = "INBOX", @@ -414,15 +484,22 @@ async def search_emails( env = msg_data.get(b"ENVELOPE") if not env: continue - results.append({ - "uid": uid, - "from": str(env.from_[0]) if env.from_ else "", - "to": [str(a) for a in (env.to or [])], - "subject": env.subject.decode(errors="replace") if env.subject else "", - "date": str(env.date) if env.date else "", - "flags": [f.decode(errors="replace") for f in msg_data.get(b"FLAGS", [])], - "size": msg_data.get(b"RFC822.SIZE", 0), - }) + results.append( + { + "uid": uid, + "from": str(env.from_[0]) if env.from_ else "", + "to": [str(a) for a in (env.to or [])], + "subject": env.subject.decode(errors="replace") + if env.subject + else "", + "date": str(env.date) if env.date else "", + "flags": [ + f.decode(errors="replace") + for f in msg_data.get(b"FLAGS", []) + ], + "size": msg_data.get(b"RFC822.SIZE", 0), + } + ) return results finally: client.logout() @@ -430,7 +507,9 @@ async def search_emails( return await asyncio.to_thread(_search) -@mcp.tool(description="Read a specific email by UID. Returns full email content including body and attachment metadata.") +@mcp.tool( + description="Read a specific email by UID. Returns full email content including body and attachment metadata." +) async def read_email( ctx: Context, uid: int, @@ -454,7 +533,9 @@ async def read_email( return await asyncio.to_thread(_read) -@mcp.tool(description="Send an email via SMTP. Supports plain text and HTML, CC/BCC, and reply threading.") +@mcp.tool( + description="Send an email via SMTP. Supports plain text and HTML, CC/BCC, and reply threading." +) async def send_email( ctx: Context, to: str, @@ -529,7 +610,9 @@ async def send_email( return await asyncio.to_thread(_send) -@mcp.tool(description="Archive an email by moving it to the Archive folder instead of deleting.") +@mcp.tool( + description="Archive an email by moving it to the Archive folder instead of deleting." +) async def archive_email( ctx: Context, uid: int, @@ -600,7 +683,9 @@ async def mark_email( "unflagged": (b"\\Flagged", "remove"), } if action not in flag_map: - return {"error": f"Invalid action: {action}. Use: read, unread, flagged, unflagged"} + return { + "error": f"Invalid action: {action}. Use: read, unread, flagged, unflagged" + } flag, op = flag_map[action] @@ -686,13 +771,23 @@ async def rename_folder( return await asyncio.to_thread(_rename) -@mcp.tool(description="Delete an IMAP folder. Refuses to delete INBOX or system folders.") +@mcp.tool( + description="Delete an IMAP folder. Refuses to delete INBOX or system folders." +) async def delete_folder( ctx: Context, name: str, account_id: Optional[str] = None, ) -> dict: - protected = {"INBOX", "[Gmail]", "[Gmail]/All Mail", "[Gmail]/Trash", "[Gmail]/Spam", "[Gmail]/Drafts", "[Gmail]/Sent Mail"} + protected = { + "INBOX", + "[Gmail]", + "[Gmail]/All Mail", + "[Gmail]/Trash", + "[Gmail]/Spam", + "[Gmail]/Drafts", + "[Gmail]/Sent Mail", + } if name in protected: return {"error": f"Cannot delete protected folder: {name}"} @@ -724,13 +819,15 @@ async def get_server_info(ctx: Context) -> dict: status = "connected" except Exception as e: status = f"error: {e}" - account_info.append({ - "id": acc["id"], - "imap_host": acc["imap_host"], - "smtp_host": acc["smtp_host"], - "watch_folders": acc.get("watch_folders", []), - "status": status, - }) + account_info.append( + { + "id": acc["id"], + "imap_host": acc["imap_host"], + "smtp_host": acc["smtp_host"], + "watch_folders": acc.get("watch_folders", []), + "status": status, + } + ) return { "server_name": "poke-mail",