Initial release: IMAP/SMTP email bridge MCP server
- 11 MCP tools: search, read, send, archive, move, mark emails + folder management - IMAP IDLE watcher with polling fallback, forwards new emails to Poke webhook - Bearer token auth (MCP_API_KEY) for securing the server - Multi-account support via config.yml - Docker + GitHub Actions CI/CD (lint, build, push to GHCR)
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.github
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
config.yml
|
||||
.env
|
||||
.gitignore
|
||||
README.md
|
||||
render.yaml
|
||||
config.example.yml
|
||||
.claude
|
||||
@@ -0,0 +1,45 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install ruff
|
||||
- run: ruff check src/
|
||||
- run: ruff format --check src/
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
- run: pip install -r requirements.txt
|
||||
- name: Verify imports
|
||||
run: python -c "from src.server import mcp; print(f'OK: {mcp.name}')"
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: poke-mail:test
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
Executable
+219
@@ -0,0 +1,219 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
# Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
# poetry.lock
|
||||
# poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
# pdm.lock
|
||||
# pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
# pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# Redis
|
||||
*.rdb
|
||||
*.aof
|
||||
*.pid
|
||||
|
||||
# RabbitMQ
|
||||
mnesia/
|
||||
rabbitmq/
|
||||
rabbitmq-data/
|
||||
|
||||
# ActiveMQ
|
||||
activemq-data/
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
# .idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
|
||||
# poke-mail config (contains credentials)
|
||||
config.yml
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
FROM python:3.13-slim AS base
|
||||
|
||||
# Prevent bytecode + enable unbuffered output
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps in a separate layer for caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy source
|
||||
COPY src/ src/
|
||||
|
||||
# Non-root user
|
||||
RUN adduser --disabled-password --no-create-home appuser
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:8000/mcp', timeout=5)" || exit 1
|
||||
|
||||
CMD ["python", "src/server.py"]
|
||||
@@ -0,0 +1,140 @@
|
||||
# poke-mail
|
||||
|
||||
An MCP server that bridges IMAP/SMTP email accounts to [Poke](https://poke.com). Provides AI agents with tools to search, read, send, and manage emails, and automatically forwards new incoming emails to the Poke inbound endpoint.
|
||||
|
||||
## Features
|
||||
|
||||
- **11 MCP tools**: search, read, send, archive, move, mark, list/create/rename/delete folders, server info
|
||||
- **IMAP IDLE watcher**: Real-time monitoring of new emails, forwarded to Poke automatically
|
||||
- **Multi-account support**: Configure multiple email accounts in a single config file
|
||||
- **Bearer token auth**: Secure the server with `MCP_API_KEY` so only you can use it
|
||||
- **No delete tool**: Emails can be archived or moved, never deleted by an agent
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Configure accounts
|
||||
|
||||
```bash
|
||||
cp config.example.yml config.yml
|
||||
```
|
||||
|
||||
Edit `config.yml` with your email credentials:
|
||||
|
||||
```yaml
|
||||
webhook_url: https://poke.com/api/v1/inbound-sms/webhook
|
||||
poke_api_key: your-api-key # from https://poke.com/settings/advanced
|
||||
|
||||
accounts:
|
||||
- id: work
|
||||
imap_host: imap.gmail.com
|
||||
imap_port: 993
|
||||
imap_username: you@gmail.com
|
||||
imap_password: your-app-password
|
||||
smtp_host: smtp.gmail.com
|
||||
smtp_port: 587
|
||||
smtp_username: you@gmail.com
|
||||
smtp_password: your-app-password
|
||||
watch_folders:
|
||||
- INBOX
|
||||
```
|
||||
|
||||
For Gmail, use an [App Password](https://support.google.com/accounts/answer/185833).
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. Run
|
||||
|
||||
```bash
|
||||
MCP_API_KEY=your-secret-key python src/server.py
|
||||
```
|
||||
|
||||
### 4. Test
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
Open http://localhost:3000 and connect to `http://localhost:8000/mcp` using "Streamable HTTP" transport. Pass `Authorization: Bearer your-secret-key` header.
|
||||
|
||||
## Authentication
|
||||
|
||||
Set `MCP_API_KEY` to secure the server. All requests must include `Authorization: Bearer <MCP_API_KEY>`.
|
||||
|
||||
If `MCP_API_KEY` is not set, the server runs unauthenticated (with a warning). **Always set it in production.**
|
||||
|
||||
When connecting from Poke, add the bearer token in your connection settings.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t poke-mail .
|
||||
|
||||
docker run -d \
|
||||
-p 8000:8000 \
|
||||
-v $(pwd)/config.yml:/app/config.yml:ro \
|
||||
-e MCP_API_KEY=your-secret-key \
|
||||
poke-mail
|
||||
```
|
||||
|
||||
Or use the pre-built image from GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 8000:8000 \
|
||||
-v $(pwd)/config.yml:/app/config.yml:ro \
|
||||
-e MCP_API_KEY=your-secret-key \
|
||||
ghcr.io/OWNER/poke-mail:main
|
||||
```
|
||||
|
||||
## MCP Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `search_emails` | Search by from, to, subject, date range |
|
||||
| `read_email` | Read full email content by UID |
|
||||
| `send_email` | Send email with text/HTML, CC/BCC, reply threading |
|
||||
| `archive_email` | Move email to Archive folder |
|
||||
| `move_email` | Move email between folders |
|
||||
| `mark_email` | Set read/unread/flagged/unflagged |
|
||||
| `list_folders` | List all IMAP folders |
|
||||
| `create_folder` | Create a new folder |
|
||||
| `rename_folder` | Rename a folder |
|
||||
| `delete_folder` | Delete a folder (protected folders blocked) |
|
||||
| `get_server_info` | Server status and account connectivity |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MCP_API_KEY` | — | **Required in production.** Bearer token to secure the MCP server |
|
||||
| `CONFIG_PATH` | `config.yml` | Path to config file |
|
||||
| `POKE_WEBHOOK_URL` | from config | Overrides webhook URL in config |
|
||||
| `POKE_API_KEY` | from config | Overrides Poke API key in config |
|
||||
| `PORT` | `8000` | HTTP server port |
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
The GitHub Actions workflow automatically builds and pushes to `ghcr.io` on every push to `main` and on tags.
|
||||
|
||||
1. Set repository secrets: none needed (uses `GITHUB_TOKEN` for GHCR)
|
||||
2. Push to `main` or tag `v*` to trigger a build
|
||||
3. Pull and run the image with your `config.yml` and `MCP_API_KEY`
|
||||
|
||||
### Render
|
||||
|
||||
1. Push to GitHub
|
||||
2. Create a Web Service on Render connected to your repo
|
||||
3. Add `MCP_API_KEY`, `POKE_API_KEY`, and `POKE_WEBHOOK_URL` as environment variables
|
||||
4. Mount your `config.yml` as a secret file or set `CONFIG_PATH`
|
||||
|
||||
## Poke Setup
|
||||
|
||||
Connect your MCP server to Poke at [poke.com/settings/connections](https://poke.com/settings/connections). Add the bearer token (`MCP_API_KEY`) in the connection auth settings.
|
||||
|
||||
The IDLE watcher automatically forwards new emails to Poke. You can also use the tools directly through Poke's AI agent.
|
||||
@@ -0,0 +1,21 @@
|
||||
# poke-mail configuration
|
||||
# Copy this file to config.yml and fill in your credentials
|
||||
|
||||
webhook_url: https://poke.com/api/v1/inbound-sms/webhook
|
||||
poke_api_key: your-api-key-here # from https://poke.com/settings/advanced
|
||||
|
||||
# MCP_API_KEY is set via environment variable (not in this file)
|
||||
# It secures the MCP server so only you can use it
|
||||
|
||||
accounts:
|
||||
- id: default
|
||||
imap_host: imap.gmail.com
|
||||
imap_port: 993
|
||||
imap_username: you@gmail.com
|
||||
imap_password: your-app-password
|
||||
smtp_host: smtp.gmail.com
|
||||
smtp_port: 587
|
||||
smtp_username: you@gmail.com
|
||||
smtp_password: your-app-password
|
||||
watch_folders:
|
||||
- INBOX
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
- type: web
|
||||
name: poke-mail
|
||||
runtime: python
|
||||
buildCommand: pip install -r requirements.txt
|
||||
startCommand: python src/server.py
|
||||
plan: free
|
||||
autoDeploy: false
|
||||
envVars:
|
||||
- key: ENVIRONMENT
|
||||
value: production
|
||||
- key: CONFIG_PATH
|
||||
value: config.yml
|
||||
- key: MCP_API_KEY
|
||||
sync: false
|
||||
- key: POKE_WEBHOOK_URL
|
||||
sync: false
|
||||
- key: POKE_API_KEY
|
||||
sync: false
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
fastmcp>=2.12.0
|
||||
uvicorn>=0.35.0
|
||||
imapclient>=3.0.0
|
||||
httpx>=0.27.0
|
||||
pyyaml>=6.0
|
||||
Executable
+751
@@ -0,0 +1,751 @@
|
||||
#!/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 email import policy
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.parser import BytesParser
|
||||
from typing import Optional
|
||||
|
||||
import hmac
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
from imapclient import IMAPClient
|
||||
from fastmcp import FastMCP, Context
|
||||
from fastmcp.server.auth import TokenVerifier, AccessToken
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("poke-mail")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth — simple bearer token verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ApiKeyAuth(TokenVerifier):
|
||||
"""Validates incoming requests against a static API key (MCP_API_KEY)."""
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
super().__init__()
|
||||
self._api_key = api_key
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
if hmac.compare_digest(token, self._api_key):
|
||||
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:
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except FileNotFoundError:
|
||||
logger.warning("Config file %s not found, using env vars", path)
|
||||
return {}
|
||||
|
||||
|
||||
def parse_accounts(config: dict) -> list[dict]:
|
||||
accounts = config.get("accounts", [])
|
||||
if not accounts:
|
||||
# Fallback to flat env vars for single account
|
||||
imap_host = os.environ.get("IMAP_HOST")
|
||||
if not imap_host:
|
||||
raise RuntimeError(
|
||||
"No accounts configured. Set POKE_MAIL_ACCOUNTS env var or create config.yml"
|
||||
)
|
||||
accounts = [
|
||||
{
|
||||
"id": "default",
|
||||
"imap_host": imap_host,
|
||||
"imap_port": int(os.environ.get("IMAP_PORT", "993")),
|
||||
"imap_username": os.environ["IMAP_USERNAME"],
|
||||
"imap_password": os.environ["IMAP_PASSWORD"],
|
||||
"smtp_host": os.environ["SMTP_HOST"],
|
||||
"smtp_port": int(os.environ.get("SMTP_PORT", "587")),
|
||||
"smtp_username": os.environ["SMTP_USERNAME"],
|
||||
"smtp_password": os.environ["SMTP_PASSWORD"],
|
||||
"watch_folders": ["INBOX"],
|
||||
}
|
||||
]
|
||||
|
||||
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)
|
||||
acc.setdefault("smtp_port", 587)
|
||||
acc.setdefault("watch_folders", ["INBOX"])
|
||||
for field in required:
|
||||
if field not in acc:
|
||||
raise RuntimeError(f"Account '{acc['id']}' missing required field: {field}")
|
||||
return accounts
|
||||
|
||||
|
||||
def resolve_account(accounts: list[dict], account_id: Optional[str] = None) -> dict:
|
||||
if not account_id:
|
||||
return accounts[0]
|
||||
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]}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IMAP helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_imap_client(account: dict) -> IMAPClient:
|
||||
port = account["imap_port"]
|
||||
use_ssl = port == 993
|
||||
client = IMAPClient(account["imap_host"], port=port, ssl=use_ssl)
|
||||
if not use_ssl:
|
||||
client.starttls()
|
||||
client.login(account["imap_username"], account["imap_password"])
|
||||
return client
|
||||
|
||||
|
||||
def parse_email_message(raw: bytes) -> dict:
|
||||
msg = BytesParser(policy=policy.default).parsebytes(raw)
|
||||
|
||||
body_text = ""
|
||||
body_html = ""
|
||||
attachments = []
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
cd = str(part.get("Content-Disposition", ""))
|
||||
if "attachment" in cd:
|
||||
try:
|
||||
content = part.get_content()
|
||||
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,
|
||||
})
|
||||
elif ct == "text/plain" and not body_text:
|
||||
try:
|
||||
body_text = part.get_content()
|
||||
except Exception:
|
||||
body_text = part.get_payload(decode=True).decode(errors="replace")
|
||||
elif ct == "text/html" and not body_html:
|
||||
try:
|
||||
body_html = part.get_content()
|
||||
except Exception:
|
||||
body_html = part.get_payload(decode=True).decode(errors="replace")
|
||||
else:
|
||||
ct = msg.get_content_type()
|
||||
try:
|
||||
content = msg.get_content()
|
||||
except Exception:
|
||||
payload = msg.get_payload(decode=True)
|
||||
content = payload.decode(errors="replace") if payload else ""
|
||||
if ct == "text/html":
|
||||
body_html = content
|
||||
else:
|
||||
body_text = content
|
||||
|
||||
to_header = msg["to"] or ""
|
||||
cc_header = msg["cc"] or ""
|
||||
|
||||
def parse_addresses(header):
|
||||
if not header:
|
||||
return []
|
||||
return [addr.strip() for addr in str(header).split(",") if addr.strip()]
|
||||
|
||||
return {
|
||||
"from": str(msg["from"] or ""),
|
||||
"to": parse_addresses(to_header),
|
||||
"cc": parse_addresses(cc_header),
|
||||
"subject": str(msg["subject"] or ""),
|
||||
"date": str(msg["date"] or ""),
|
||||
"body_text": body_text,
|
||||
"body_html": body_html,
|
||||
"headers": {k: str(v) for k, v in msg.items()},
|
||||
"attachments": attachments,
|
||||
}
|
||||
|
||||
|
||||
def build_search_criteria(
|
||||
from_addr: Optional[str] = None,
|
||||
to_addr: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
before: Optional[str] = None,
|
||||
) -> list:
|
||||
criteria = []
|
||||
if from_addr:
|
||||
criteria.extend(["FROM", from_addr])
|
||||
if to_addr:
|
||||
criteria.extend(["TO", to_addr])
|
||||
if subject:
|
||||
criteria.extend(["SUBJECT", subject])
|
||||
if since:
|
||||
criteria.extend(["SINCE", date.fromisoformat(since)])
|
||||
if before:
|
||||
criteria.extend(["BEFORE", date.fromisoformat(before)])
|
||||
if not criteria:
|
||||
criteria = ["ALL"]
|
||||
return criteria
|
||||
|
||||
|
||||
def detect_archive_folder(client: IMAPClient) -> str:
|
||||
folders = client.list_folders()
|
||||
for flags, _delim, name in folders:
|
||||
if b"\\Archive" in flags:
|
||||
return name
|
||||
if name in ("[Gmail]/All Mail", "Archive"):
|
||||
return name
|
||||
return "Archive"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Poke webhook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def forward_to_poke(email_data: dict, webhook_url: str, api_key: str) -> bool:
|
||||
payload = {
|
||||
"from": email_data["from"],
|
||||
"to": email_data["to"],
|
||||
"subject": email_data["subject"],
|
||||
"date": email_data["date"],
|
||||
"body_text": email_data["body_text"],
|
||||
"body_html": email_data["body_html"],
|
||||
"headers": email_data.get("headers", {}),
|
||||
"attachments": email_data.get("attachments", []),
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
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)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Forward attempt %d failed: %s", attempt + 1, e)
|
||||
if attempt == 0:
|
||||
await asyncio.sleep(2)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IDLE watcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def watch_folder(account: dict, folder: str, webhook_url: str, api_key: str, stop_event: asyncio.Event):
|
||||
backoff = 5
|
||||
max_backoff = 60
|
||||
|
||||
while not stop_event.is_set():
|
||||
client = None
|
||||
try:
|
||||
client = await asyncio.to_thread(get_imap_client, account)
|
||||
await asyncio.to_thread(client.select_folder, folder)
|
||||
|
||||
# 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)
|
||||
return
|
||||
|
||||
logger.info("[%s/%s] Watching for new emails via IDLE", account["id"], folder)
|
||||
backoff = 5
|
||||
|
||||
while not stop_event.is_set():
|
||||
await asyncio.to_thread(client.idle)
|
||||
try:
|
||||
responses = await asyncio.to_thread(client.idle_check, 120)
|
||||
except Exception:
|
||||
try:
|
||||
await asyncio.to_thread(client.idle_done)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
await asyncio.to_thread(client.idle_done)
|
||||
|
||||
has_new = any(
|
||||
isinstance(r, tuple) and len(r) >= 2 and r[1] == b"EXISTS"
|
||||
for r in responses
|
||||
)
|
||||
if not has_new:
|
||||
continue
|
||||
|
||||
uids = await asyncio.to_thread(client.search, ["UNSEEN"])
|
||||
if not uids:
|
||||
continue
|
||||
|
||||
raw_messages = await asyncio.to_thread(client.fetch, uids, ["RFC822"])
|
||||
for uid, data in raw_messages.items():
|
||||
raw = data.get(b"RFC822", b"")
|
||||
if not raw:
|
||||
continue
|
||||
email_data = parse_email_message(raw)
|
||||
await forward_to_poke(email_data, webhook_url, api_key)
|
||||
|
||||
await asyncio.to_thread(client.set_flags, uids, [b"\\Seen"])
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
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:
|
||||
if client:
|
||||
try:
|
||||
await asyncio.to_thread(client.logout)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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():
|
||||
try:
|
||||
uids = await asyncio.to_thread(client.search, ["UNSEEN"])
|
||||
if uids:
|
||||
raw_messages = await asyncio.to_thread(client.fetch, uids, ["RFC822"])
|
||||
for uid, data in raw_messages.items():
|
||||
raw = data.get(b"RFC822", b"")
|
||||
if not raw:
|
||||
continue
|
||||
email_data = parse_email_message(raw)
|
||||
await forward_to_poke(email_data, webhook_url, api_key)
|
||||
await asyncio.to_thread(client.set_flags, uids, [b"\\Seen"])
|
||||
except Exception as e:
|
||||
logger.warning("[%s/%s] Poll error: %s", account["id"], folder, e)
|
||||
raise # reconnect via outer loop
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
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)))
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"))
|
||||
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))
|
||||
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}
|
||||
finally:
|
||||
stop_event.set()
|
||||
watcher_task.cancel()
|
||||
try:
|
||||
await watcher_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("poke-mail shut down")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP Server & Tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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.")
|
||||
|
||||
mcp = FastMCP("poke-mail", lifespan=lifespan, auth=auth)
|
||||
|
||||
|
||||
@mcp.tool(description="Search emails by criteria. Returns a list of matching emails with metadata.")
|
||||
async def search_emails(
|
||||
ctx: Context,
|
||||
folder: str = "INBOX",
|
||||
account_id: Optional[str] = None,
|
||||
from_addr: Optional[str] = None,
|
||||
to_addr: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
before: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> list[dict]:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
criteria = build_search_criteria(from_addr, to_addr, subject, since, before)
|
||||
|
||||
def _search():
|
||||
client = get_imap_client(acc)
|
||||
try:
|
||||
client.select_folder(folder, readonly=True)
|
||||
uids = client.search(criteria)
|
||||
uids = uids[-limit:] # most recent
|
||||
if not uids:
|
||||
return []
|
||||
data = client.fetch(uids, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
|
||||
results = []
|
||||
for uid, msg_data in data.items():
|
||||
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),
|
||||
})
|
||||
return results
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
return await asyncio.to_thread(_search)
|
||||
|
||||
|
||||
@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,
|
||||
folder: str = "INBOX",
|
||||
account_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
|
||||
def _read():
|
||||
client = get_imap_client(acc)
|
||||
try:
|
||||
client.select_folder(folder, readonly=True)
|
||||
data = client.fetch([uid], ["RFC822"])
|
||||
if uid not in data:
|
||||
return {"error": f"Email UID {uid} not found in {folder}"}
|
||||
return parse_email_message(data[uid][b"RFC822"])
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
return await asyncio.to_thread(_read)
|
||||
|
||||
|
||||
@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,
|
||||
subject: str,
|
||||
body: str,
|
||||
account_id: Optional[str] = None,
|
||||
cc: Optional[str] = None,
|
||||
bcc: Optional[str] = None,
|
||||
html: Optional[str] = None,
|
||||
reply_to_uid: Optional[int] = None,
|
||||
reply_to_folder: Optional[str] = None,
|
||||
) -> dict:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
|
||||
def _send():
|
||||
msg = MIMEMultipart("alternative") if html else MIMEText(body)
|
||||
if html:
|
||||
msg.attach(MIMEText(body, "plain"))
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
msg["From"] = acc["smtp_username"]
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
if cc:
|
||||
msg["Cc"] = cc
|
||||
|
||||
# Threading headers for replies
|
||||
if reply_to_uid and reply_to_folder:
|
||||
imap = None
|
||||
try:
|
||||
imap = get_imap_client(acc)
|
||||
imap.select_folder(reply_to_folder or "INBOX", readonly=True)
|
||||
orig_data = imap.fetch([reply_to_uid], ["RFC822.HEADER"])
|
||||
if reply_to_uid in orig_data:
|
||||
orig = BytesParser(policy=policy.default).parsebytes(
|
||||
orig_data[reply_to_uid][b"RFC822.HEADER"]
|
||||
)
|
||||
if orig["Message-ID"]:
|
||||
msg["In-Reply-To"] = orig["Message-ID"]
|
||||
refs = orig.get("References", "")
|
||||
msg["References"] = f"{refs} {orig['Message-ID']}".strip()
|
||||
except Exception as e:
|
||||
logger.warning("Could not fetch reply headers: %s", e)
|
||||
finally:
|
||||
if imap:
|
||||
try:
|
||||
imap.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
recipients = [addr.strip() for addr in to.split(",")]
|
||||
if cc:
|
||||
recipients.extend(addr.strip() for addr in cc.split(","))
|
||||
if bcc:
|
||||
recipients.extend(addr.strip() for addr in bcc.split(","))
|
||||
|
||||
port = acc["smtp_port"]
|
||||
if port == 465:
|
||||
ctx_ssl = ssl.create_default_context()
|
||||
with smtplib.SMTP_SSL(acc["smtp_host"], port, context=ctx_ssl) as smtp:
|
||||
smtp.login(acc["smtp_username"], acc["smtp_password"])
|
||||
smtp.sendmail(acc["smtp_username"], recipients, msg.as_string())
|
||||
else:
|
||||
with smtplib.SMTP(acc["smtp_host"], port) as smtp:
|
||||
smtp.starttls()
|
||||
smtp.login(acc["smtp_username"], acc["smtp_password"])
|
||||
smtp.sendmail(acc["smtp_username"], recipients, msg.as_string())
|
||||
|
||||
return {"success": True, "message_id": msg.get("Message-ID", "")}
|
||||
|
||||
return await asyncio.to_thread(_send)
|
||||
|
||||
|
||||
@mcp.tool(description="Archive an email by moving it to the Archive folder instead of deleting.")
|
||||
async def archive_email(
|
||||
ctx: Context,
|
||||
uid: int,
|
||||
folder: str = "INBOX",
|
||||
account_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
|
||||
def _archive():
|
||||
client = get_imap_client(acc)
|
||||
try:
|
||||
client.select_folder(folder)
|
||||
archive_folder = detect_archive_folder(client)
|
||||
# Ensure archive folder exists
|
||||
if not client.folder_exists(archive_folder):
|
||||
client.create_folder(archive_folder)
|
||||
client.copy([uid], archive_folder)
|
||||
client.delete_messages([uid])
|
||||
client.expunge()
|
||||
return {"success": True, "archived_to": archive_folder}
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
return await asyncio.to_thread(_archive)
|
||||
|
||||
|
||||
@mcp.tool(description="Move an email from one folder to another.")
|
||||
async def move_email(
|
||||
ctx: Context,
|
||||
uid: int,
|
||||
to_folder: str,
|
||||
from_folder: str = "INBOX",
|
||||
account_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
|
||||
def _move():
|
||||
client = get_imap_client(acc)
|
||||
try:
|
||||
client.select_folder(from_folder)
|
||||
client.copy([uid], to_folder)
|
||||
client.delete_messages([uid])
|
||||
client.expunge()
|
||||
return {"success": True, "moved_to": to_folder}
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
return await asyncio.to_thread(_move)
|
||||
|
||||
|
||||
@mcp.tool(description="Mark an email as read, unread, flagged, or unflagged.")
|
||||
async def mark_email(
|
||||
ctx: Context,
|
||||
uid: int,
|
||||
action: str,
|
||||
folder: str = "INBOX",
|
||||
account_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
|
||||
flag_map = {
|
||||
"read": (b"\\Seen", "add"),
|
||||
"unread": (b"\\Seen", "remove"),
|
||||
"flagged": (b"\\Flagged", "add"),
|
||||
"unflagged": (b"\\Flagged", "remove"),
|
||||
}
|
||||
if action not in flag_map:
|
||||
return {"error": f"Invalid action: {action}. Use: read, unread, flagged, unflagged"}
|
||||
|
||||
flag, op = flag_map[action]
|
||||
|
||||
def _mark():
|
||||
client = get_imap_client(acc)
|
||||
try:
|
||||
client.select_folder(folder)
|
||||
if op == "add":
|
||||
client.add_flags([uid], [flag])
|
||||
else:
|
||||
client.remove_flags([uid], [flag])
|
||||
return {"success": True}
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
return await asyncio.to_thread(_mark)
|
||||
|
||||
|
||||
@mcp.tool(description="List all IMAP folders for an account.")
|
||||
async def list_folders(
|
||||
ctx: Context,
|
||||
account_id: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
|
||||
def _list():
|
||||
client = get_imap_client(acc)
|
||||
try:
|
||||
folders = client.list_folders()
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"flags": [f.decode(errors="replace") for f in flags],
|
||||
"delimiter": delim.decode(errors="replace") if delim else "/",
|
||||
}
|
||||
for flags, delim, name in folders
|
||||
]
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
return await asyncio.to_thread(_list)
|
||||
|
||||
|
||||
@mcp.tool(description="Create a new IMAP folder.")
|
||||
async def create_folder(
|
||||
ctx: Context,
|
||||
name: str,
|
||||
account_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
|
||||
def _create():
|
||||
client = get_imap_client(acc)
|
||||
try:
|
||||
client.create_folder(name)
|
||||
return {"success": True}
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
return await asyncio.to_thread(_create)
|
||||
|
||||
|
||||
@mcp.tool(description="Rename an existing IMAP folder.")
|
||||
async def rename_folder(
|
||||
ctx: Context,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
account_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
|
||||
def _rename():
|
||||
client = get_imap_client(acc)
|
||||
try:
|
||||
client.rename_folder(old_name, new_name)
|
||||
return {"success": True}
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
return await asyncio.to_thread(_rename)
|
||||
|
||||
|
||||
@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"}
|
||||
if name in protected:
|
||||
return {"error": f"Cannot delete protected folder: {name}"}
|
||||
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
|
||||
def _delete():
|
||||
client = get_imap_client(acc)
|
||||
try:
|
||||
client.delete_folder(name)
|
||||
return {"success": True}
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
return await asyncio.to_thread(_delete)
|
||||
|
||||
|
||||
@mcp.tool(description="Get server information and account connection status.")
|
||||
async def get_server_info(ctx: Context) -> dict:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
webhook_url = ctx.lifespan_context["webhook_url"]
|
||||
|
||||
account_info = []
|
||||
for acc in accounts:
|
||||
status = "unknown"
|
||||
try:
|
||||
client = await asyncio.to_thread(get_imap_client, acc)
|
||||
await asyncio.to_thread(client.logout)
|
||||
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,
|
||||
})
|
||||
|
||||
return {
|
||||
"server_name": "poke-mail",
|
||||
"version": "1.0.0",
|
||||
"accounts": account_info,
|
||||
"webhook_url": webhook_url,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 8000))
|
||||
host = "0.0.0.0"
|
||||
logger.info("Starting poke-mail on %s:%d", host, port)
|
||||
mcp.run(transport="http", host=host, port=port, stateless_http=True)
|
||||
Reference in New Issue
Block a user