commit eb76dbb4892a901c6e34ef2845d17cc5fe4e922e Author: Kacper Kwapisz Date: Sun Mar 22 21:46:14 2026 +0100 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) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6d9fcbb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.venv +__pycache__ +*.pyc +config.yml +.env +.gitignore +README.md +render.yaml +config.example.yml +.claude diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4b28f1c --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..94f604b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..2bf6ca7 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c1a7907 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100755 index 0000000..3409d41 --- /dev/null +++ b/README.md @@ -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 `. + +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. diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..2657240 --- /dev/null +++ b/config.example.yml @@ -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 diff --git a/render.yaml b/render.yaml new file mode 100755 index 0000000..963c453 --- /dev/null +++ b/render.yaml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..029438c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastmcp>=2.12.0 +uvicorn>=0.35.0 +imapclient>=3.0.0 +httpx>=0.27.0 +pyyaml>=6.0 diff --git a/src/server.py b/src/server.py new file mode 100755 index 0000000..0f6d589 --- /dev/null +++ b/src/server.py @@ -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)