Fix lint: remove unused imports, apply ruff formatting

This commit is contained in:
Kacper Kwapisz
2026-03-22 21:52:37 +01:00
parent 96d5e7d4e3
commit fc9a2bb8f8
+131 -34
View File
@@ -1,12 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio import asyncio
import json
import logging import logging
import os import os
import smtplib import smtplib
import ssl import ssl
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import date, datetime from datetime import date
from email import policy from email import policy
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
@@ -29,6 +28,7 @@ logger = logging.getLogger("poke-mail")
# Auth — simple bearer token verification # Auth — simple bearer token verification
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ApiKeyAuth(TokenVerifier): class ApiKeyAuth(TokenVerifier):
"""Validates incoming requests against a static API key (MCP_API_KEY).""" """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 AccessToken(token=token, client_id="owner", scopes=["all"])
return None return None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Config # Config
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def load_config() -> dict: def load_config() -> dict:
path = os.environ.get("CONFIG_PATH", "config.yml") path = os.environ.get("CONFIG_PATH", "config.yml")
try: 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): for i, acc in enumerate(accounts):
acc.setdefault("id", f"account-{i}") acc.setdefault("id", f"account-{i}")
acc.setdefault("imap_port", 993) acc.setdefault("imap_port", 993)
@@ -87,7 +96,9 @@ def parse_accounts(config: dict) -> list[dict]:
acc.setdefault("watch_folders", ["INBOX"]) acc.setdefault("watch_folders", ["INBOX"])
for field in required: for field in required:
if field not in acc: 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 return accounts
@@ -97,13 +108,16 @@ def resolve_account(accounts: list[dict], account_id: Optional[str] = None) -> d
for acc in accounts: for acc in accounts:
if acc["id"] == account_id: if acc["id"] == account_id:
return acc 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 # IMAP helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def get_imap_client(account: dict) -> IMAPClient: def get_imap_client(account: dict) -> IMAPClient:
port = account["imap_port"] port = account["imap_port"]
use_ssl = port == 993 use_ssl = port == 993
@@ -128,14 +142,16 @@ def parse_email_message(raw: bytes) -> dict:
if "attachment" in cd: if "attachment" in cd:
try: try:
content = part.get_content() content = part.get_content()
size = len(content) if hasattr(content, '__len__') else 0 size = len(content) if hasattr(content, "__len__") else 0
except Exception: except Exception:
size = 0 size = 0
attachments.append({ attachments.append(
{
"filename": part.get_filename() or "unnamed", "filename": part.get_filename() or "unnamed",
"content_type": ct, "content_type": ct,
"size": size, "size": size,
}) }
)
elif ct == "text/plain" and not body_text: elif ct == "text/plain" and not body_text:
try: try:
body_text = part.get_content() body_text = part.get_content()
@@ -216,6 +232,7 @@ def detect_archive_folder(client: IMAPClient) -> str:
# Poke webhook # Poke webhook
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def forward_to_poke(email_data: dict, webhook_url: str, api_key: str) -> bool: async def forward_to_poke(email_data: dict, webhook_url: str, api_key: str) -> bool:
payload = { payload = {
"from": email_data["from"], "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: async with httpx.AsyncClient(timeout=30) as http:
resp = await http.post(webhook_url, json=payload, headers=headers) resp = await http.post(webhook_url, json=payload, headers=headers)
resp.raise_for_status() 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 return True
except Exception as e: except Exception as e:
logger.warning("Forward attempt %d failed: %s", attempt + 1, 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 # 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 backoff = 5
max_backoff = 60 max_backoff = 60
@@ -261,11 +289,19 @@ async def watch_folder(account: dict, folder: str, webhook_url: str, api_key: st
# Check IDLE support # Check IDLE support
if not client.has_capability("IDLE"): if not client.has_capability("IDLE"):
logger.warning("[%s/%s] Server does not support IDLE, falling back to polling", account["id"], folder) logger.warning(
await _poll_folder(client, account, folder, webhook_url, api_key, stop_event) "[%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 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 backoff = 5
while not stop_event.is_set(): 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: except asyncio.CancelledError:
break break
except Exception as e: 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) await asyncio.sleep(backoff)
backoff = min(backoff * 2, max_backoff) backoff = min(backoff * 2, max_backoff)
finally: finally:
@@ -315,7 +357,14 @@ async def watch_folder(account: dict, folder: str, webhook_url: str, api_key: st
pass 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.""" """Fallback polling for servers without IDLE support. Checks every 60 seconds."""
logger.info("[%s/%s] Polling for new emails every 60s", account["id"], folder) logger.info("[%s/%s] Polling for new emails every 60s", account["id"], folder)
while not stop_event.is_set(): 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) 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 = [] tasks = []
for acc in accounts: for acc in accounts:
for folder in acc.get("watch_folders", ["INBOX"]): 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: if tasks:
await asyncio.gather(*tasks, return_exceptions=True) 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 # Lifespan
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@asynccontextmanager @asynccontextmanager
async def lifespan(server: FastMCP): async def lifespan(server: FastMCP):
config = load_config() config = load_config()
accounts = parse_accounts(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", "")) api_key = os.environ.get("POKE_API_KEY", config.get("poke_api_key", ""))
stop_event = asyncio.Event() 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)) logger.info("poke-mail started with %d account(s)", len(accounts))
try: 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: finally:
stop_event.set() stop_event.set()
watcher_task.cancel() watcher_task.cancel()
@@ -379,12 +445,16 @@ async def lifespan(server: FastMCP):
mcp_api_key = os.environ.get("MCP_API_KEY", "") mcp_api_key = os.environ.get("MCP_API_KEY", "")
auth = ApiKeyAuth(mcp_api_key) if mcp_api_key else None auth = ApiKeyAuth(mcp_api_key) if mcp_api_key else None
if not mcp_api_key: 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 = 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( async def search_emails(
ctx: Context, ctx: Context,
folder: str = "INBOX", folder: str = "INBOX",
@@ -414,15 +484,22 @@ async def search_emails(
env = msg_data.get(b"ENVELOPE") env = msg_data.get(b"ENVELOPE")
if not env: if not env:
continue continue
results.append({ results.append(
{
"uid": uid, "uid": uid,
"from": str(env.from_[0]) if env.from_ else "", "from": str(env.from_[0]) if env.from_ else "",
"to": [str(a) for a in (env.to or [])], "to": [str(a) for a in (env.to or [])],
"subject": env.subject.decode(errors="replace") if env.subject else "", "subject": env.subject.decode(errors="replace")
if env.subject
else "",
"date": str(env.date) if env.date else "", "date": str(env.date) if env.date else "",
"flags": [f.decode(errors="replace") for f in msg_data.get(b"FLAGS", [])], "flags": [
f.decode(errors="replace")
for f in msg_data.get(b"FLAGS", [])
],
"size": msg_data.get(b"RFC822.SIZE", 0), "size": msg_data.get(b"RFC822.SIZE", 0),
}) }
)
return results return results
finally: finally:
client.logout() client.logout()
@@ -430,7 +507,9 @@ async def search_emails(
return await asyncio.to_thread(_search) 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( async def read_email(
ctx: Context, ctx: Context,
uid: int, uid: int,
@@ -454,7 +533,9 @@ async def read_email(
return await asyncio.to_thread(_read) 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( async def send_email(
ctx: Context, ctx: Context,
to: str, to: str,
@@ -529,7 +610,9 @@ async def send_email(
return await asyncio.to_thread(_send) 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( async def archive_email(
ctx: Context, ctx: Context,
uid: int, uid: int,
@@ -600,7 +683,9 @@ async def mark_email(
"unflagged": (b"\\Flagged", "remove"), "unflagged": (b"\\Flagged", "remove"),
} }
if action not in flag_map: 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] flag, op = flag_map[action]
@@ -686,13 +771,23 @@ async def rename_folder(
return await asyncio.to_thread(_rename) 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( async def delete_folder(
ctx: Context, ctx: Context,
name: str, name: str,
account_id: Optional[str] = None, account_id: Optional[str] = None,
) -> dict: ) -> 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: if name in protected:
return {"error": f"Cannot delete protected folder: {name}"} return {"error": f"Cannot delete protected folder: {name}"}
@@ -724,13 +819,15 @@ async def get_server_info(ctx: Context) -> dict:
status = "connected" status = "connected"
except Exception as e: except Exception as e:
status = f"error: {e}" status = f"error: {e}"
account_info.append({ account_info.append(
{
"id": acc["id"], "id": acc["id"],
"imap_host": acc["imap_host"], "imap_host": acc["imap_host"],
"smtp_host": acc["smtp_host"], "smtp_host": acc["smtp_host"],
"watch_folders": acc.get("watch_folders", []), "watch_folders": acc.get("watch_folders", []),
"status": status, "status": status,
}) }
)
return { return {
"server_name": "poke-mail", "server_name": "poke-mail",