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:
Kacper Kwapisz
2026-03-22 21:46:14 +01:00
commit eb76dbb489
10 changed files with 1284 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.github
.venv
__pycache__
*.pyc
config.yml
.env
.gitignore
README.md
render.yaml
config.example.yml
.claude
+45
View File
@@ -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
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
Executable
+140
View File
@@ -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.
+21
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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)