Fix lint: remove unused imports, apply ruff formatting
This commit is contained in:
+131
-34
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user